From 2b5c6dc4cfac48b3734e9716fc41604a4fc7f51d Mon Sep 17 00:00:00 2001 From: Arnoud Vermeer Date: Wed, 7 Mar 2018 11:46:01 +0100 Subject: [PATCH] Develop package model (#4) * Initial model for Packages model * Add package_list * Add migrations * Adding package add form * Import CSV Packages feature * Adding filters * Working bulkedit functionality for packages * Require a package for a customer circuit * Add edit and delete views for packages * Add API support * Expose package detail via the API endpoint * NETOPS-704 Rename Tenant Group to Service Providers and Tenants to Customers * NETOPS-704 Some small detail names fixed * Import Rich's changes --- netbox/circuits/api/serializers.py | 7 +- netbox/circuits/forms.py | 14 ++- .../migrations/0010_circuit_package.py | 27 +++++ netbox/circuits/models.py | 5 +- .../migrations/0022_merge_20180222_1114.py | 16 +++ netbox/ipam/migrations/0023_package_model.py | 20 ++++ netbox/netbox/views.py | 3 +- netbox/templates/circuits/circuit.html | 10 ++ netbox/templates/circuits/circuit_edit.html | 3 +- netbox/templates/dcim/device_edit.html | 2 +- netbox/templates/dcim/rack_edit.html | 2 +- netbox/templates/dcim/site_edit.html | 2 +- netbox/templates/home.html | 8 +- netbox/templates/inc/nav_menu.html | 15 ++- netbox/templates/ipam/ipaddress_bulk_add.html | 2 +- netbox/templates/ipam/ipaddress_edit.html | 2 +- netbox/templates/ipam/prefix_edit.html | 2 +- netbox/templates/ipam/vlan_edit.html | 2 +- netbox/templates/ipam/vrf_edit.html | 2 +- .../templates/tenancy/inc/speed_widget.html | 12 ++ netbox/templates/tenancy/package.html | 104 ++++++++++++++++++ netbox/templates/tenancy/package_edit.html | 58 ++++++++++ netbox/templates/tenancy/package_list.html | 21 ++++ netbox/templates/tenancy/tenant.html | 8 +- netbox/templates/tenancy/tenant_edit.html | 2 +- netbox/templates/tenancy/tenant_list.html | 2 +- .../templates/tenancy/tenantgroup_list.html | 2 +- .../virtualization/virtualmachine_edit.html | 2 +- netbox/tenancy/api/serializers.py | 28 ++++- netbox/tenancy/api/urls.py | 1 + netbox/tenancy/api/views.py | 13 ++- netbox/tenancy/constants.py | 17 +++ netbox/tenancy/filters.py | 30 ++++- netbox/tenancy/forms.py | 103 ++++++++++++++++- .../tenancy/migrations/0004_package_model.py | 42 +++++++ netbox/tenancy/models.py | 54 +++++++++ netbox/tenancy/tables.py | 23 +++- netbox/tenancy/urls.py | 36 +++--- netbox/tenancy/views.py | 60 +++++++++- netbox/utilities/templatetags/helpers.py | 2 +- 40 files changed, 709 insertions(+), 55 deletions(-) create mode 100644 netbox/circuits/migrations/0010_circuit_package.py create mode 100644 netbox/ipam/migrations/0022_merge_20180222_1114.py create mode 100644 netbox/ipam/migrations/0023_package_model.py create mode 100644 netbox/templates/tenancy/inc/speed_widget.html create mode 100644 netbox/templates/tenancy/package.html create mode 100644 netbox/templates/tenancy/package_edit.html create mode 100644 netbox/templates/tenancy/package_list.html create mode 100644 netbox/tenancy/constants.py create mode 100644 netbox/tenancy/migrations/0004_package_model.py diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index d2432374f3d..66d5f2d5519 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -5,7 +5,7 @@ from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer from extras.api.customfields import CustomFieldModelSerializer -from tenancy.api.serializers import NestedTenantSerializer +from tenancy.api.serializers import NestedTenantSerializer, NestedPackageSerializer from utilities.api import ValidatedModelSerializer @@ -68,11 +68,12 @@ class CircuitSerializer(CustomFieldModelSerializer): provider = NestedProviderSerializer() type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer() + package = NestedPackageSerializer() class Meta: model = Circuit fields = [ - 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + 'id', 'cid', 'provider', 'type', 'tenant', 'package', 'install_date', 'commit_rate', 'description', 'comments', 'custom_fields', ] @@ -90,7 +91,7 @@ class WritableCircuitSerializer(CustomFieldModelSerializer): class Meta: model = Circuit fields = [ - 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + 'id', 'cid', 'provider', 'type', 'tenant', 'package', 'install_date', 'commit_rate', 'description', 'comments', 'custom_fields', ] diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index d843a62e774..03f2b5bf0b6 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -13,7 +13,6 @@ ) from .models import Circuit, CircuitTermination, CircuitType, Provider - # # Providers # @@ -105,7 +104,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = Circuit fields = [ - 'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', + 'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', 'package', 'comments', ] help_texts = { @@ -114,6 +113,17 @@ class Meta: 'commit_rate': "Committed rate", } + def clean(self): + + super(CircuitForm, self).clean() + + ctype = self.cleaned_data.get('type') + package = self.cleaned_data.get('package') + + # Validate interface + if ctype.slug == 'customer' and package == None: + raise forms.ValidationError("A package is required for a customer circuit") + class CircuitCSVForm(forms.ModelForm): provider = forms.ModelChoiceField( diff --git a/netbox/circuits/migrations/0010_circuit_package.py b/netbox/circuits/migrations/0010_circuit_package.py new file mode 100644 index 00000000000..cf75335d452 --- /dev/null +++ b/netbox/circuits/migrations/0010_circuit_package.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.10 on 2018-02-26 10:35 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_package_model'), + ('circuits', '0009_unicode_literals'), + ] + + operations = [ + migrations.AddField( + model_name='circuit', + name='package', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='package', to='tenancy.Package'), + ), + migrations.AlterField( + model_name='circuittermination', + name='term_side', + field=models.CharField(choices=[('A', 'A'), ('Y', 'Y'), ('Z', 'Z')], max_length=1, verbose_name='Termination'), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 4e0e5f6ec21..03aa5a7e495 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -7,7 +7,7 @@ from dcim.fields import ASNField from extras.models import CustomFieldModel, CustomFieldValue -from tenancy.models import Tenant +from tenancy.models import Tenant, Package from utilities.models import CreatedUpdatedModel from .constants import * @@ -90,13 +90,14 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT) type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT) tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT) + package = models.ForeignKey(Package, related_name='package', blank=True, null=True, on_delete=models.SET_NULL) install_date = models.DateField(blank=True, null=True, verbose_name='Date installed') commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)') description = models.CharField(max_length=100, blank=True) comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] + csv_headers = ['cid', 'provider', 'type', 'tenant', 'package', 'install_date', 'commit_rate', 'description', 'comments'] class Meta: ordering = ['provider', 'cid'] diff --git a/netbox/ipam/migrations/0022_merge_20180222_1114.py b/netbox/ipam/migrations/0022_merge_20180222_1114.py new file mode 100644 index 00000000000..18ed127e0cb --- /dev/null +++ b/netbox/ipam/migrations/0022_merge_20180222_1114.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.9 on 2018-02-22 11:14 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0021_outer_vlan_id'), + ('ipam', '0021_vrf_ordering'), + ] + + operations = [ + ] diff --git a/netbox/ipam/migrations/0023_package_model.py b/netbox/ipam/migrations/0023_package_model.py new file mode 100644 index 00000000000..9500276f764 --- /dev/null +++ b/netbox/ipam/migrations/0023_package_model.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.10 on 2018-02-22 15:38 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0022_merge_20180222_1114'), + ] + + operations = [ + migrations.AlterField( + model_name='ipaddress', + name='role', + field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (15, 'Static route'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP'), (44, 'CARP')], help_text='The functional role of this IP', null=True, verbose_name='Role'), + ), + ] diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 0f240fff3a4..f2973d6e9f1 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -23,7 +23,7 @@ from secrets.models import Secret from secrets.tables import SecretTable from tenancy.filters import TenantFilter -from tenancy.models import Tenant +from tenancy.models import Tenant, Package from tenancy.tables import TenantTable from virtualization.filters import ClusterFilter, VirtualMachineFilter from virtualization.models import Cluster, VirtualMachine @@ -145,6 +145,7 @@ def get(self, request): # Organization 'site_count': Site.objects.count(), 'tenant_count': Tenant.objects.count(), + 'package_count': Package.objects.count(), # DCIM 'rack_count': Rack.objects.count(), diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 3fb42213c19..569252d9009 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -74,6 +74,16 @@

{% block title %}{{ circuit.provider }} - {{ circuit.cid }}{% endblock %} + + Package + + {% if circuit.package %} + {{ circuit.package }} + {% else %} + N/A + {% endif %} + + Install Date diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 63d38ef5205..23110552423 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/netbox/templates/circuits/circuit_edit.html @@ -8,6 +8,7 @@ {% render_field form.provider %} {% render_field form.cid %} {% render_field form.type %} + {% render_field form.package %} {% render_field form.install_date %}
@@ -23,7 +24,7 @@
-
Tenancy
+
{{ form.tenant_group.label }}/{{ form.tenant.label }}
{% render_field form.tenant_group %} {% render_field form.tenant %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 07206ca2711..e97594edc7a 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -63,7 +63,7 @@
-
Tenancy
+
{{ form.tenant_group.label }}/{{ form.tenant.label }}
{% render_field form.tenant_group %} {% render_field form.tenant %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 4ab129a1ddf..c8b9fa32f0c 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -14,7 +14,7 @@
-
Tenancy
+
{{ form.tenant_group.label }}/{{ form.tenant.label }}
{% render_field form.tenant_group %} {% render_field form.tenant %} diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index a1c13075a51..74b34603d6c 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -13,7 +13,7 @@
-
Tenancy
+
{{ form.tenant_group.label }}/{{ form.tenant.label }}
{% render_field form.tenant_group %} {% render_field form.tenant %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 46bfdbbd583..4d94ebbfb50 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -16,8 +16,12 @@

Sites
{{ stats.tenant_count }} -

Tenants

-

Customers or departments

+

Customers

+
+
+ {{ stats.package_count }} +

Packages

+

Sales packages

diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 1857afcc2c1..7b2bee35f2b 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -39,7 +39,7 @@ Regions
  • - +
  • {% if perms.tenancy.add_tenant %}
    @@ -47,7 +47,7 @@
    {% endif %} - Tenants + Customers
  • {% if perms.tenancy.add_tenantgroup %} @@ -56,7 +56,16 @@ {% endif %} - Tenant Groups + Service providers +
  • +
  • + {% if perms.tenancy.add_package %} +
    + + +
    + {% endif %} + Packages
  • diff --git a/netbox/templates/ipam/ipaddress_bulk_add.html b/netbox/templates/ipam/ipaddress_bulk_add.html index 17d48388ce3..444a657ca56 100644 --- a/netbox/templates/ipam/ipaddress_bulk_add.html +++ b/netbox/templates/ipam/ipaddress_bulk_add.html @@ -20,7 +20,7 @@
    -
    Tenancy
    +
    {{ form.tenant_group.label }}/{{ form.tenant.label }}
    {% render_field model_form.tenant_group %} {% render_field model_form.tenant %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index d0dad69ee11..2cfe0c6db6c 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -21,7 +21,7 @@
    -
    Tenancy
    +
    {{ form.tenant_group.label }}/{{ form.tenant.label }}
    {% render_field form.tenant_group %} {% render_field form.tenant %} diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html index 938a75da335..0c0a1e2c9af 100644 --- a/netbox/templates/ipam/prefix_edit.html +++ b/netbox/templates/ipam/prefix_edit.html @@ -22,7 +22,7 @@
    -
    Tenancy
    +
    {{ form.tenant_group.label }}/{{ form.tenant.label }}
    {% render_field form.tenant_group %} {% render_field form.tenant %} diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 3bfb7783e70..32628e36878 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -15,7 +15,7 @@
    -
    Tenancy
    +
    {{ form.tenant_group.label }}/{{ form.tenant.label }}
    {% render_field form.tenant_group %} {% render_field form.tenant %} diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html index 63052129cbd..a09d5dd32da 100644 --- a/netbox/templates/ipam/vrf_edit.html +++ b/netbox/templates/ipam/vrf_edit.html @@ -12,7 +12,7 @@
    -
    Tenancy
    +
    {{ form.tenant_group.label }}/{{ form.tenant.label }}
    {% render_field form.tenant_group %} {% render_field form.tenant %} diff --git a/netbox/templates/tenancy/inc/speed_widget.html b/netbox/templates/tenancy/inc/speed_widget.html new file mode 100644 index 00000000000..6f644d345c4 --- /dev/null +++ b/netbox/templates/tenancy/inc/speed_widget.html @@ -0,0 +1,12 @@ + + + + diff --git a/netbox/templates/tenancy/package.html b/netbox/templates/tenancy/package.html new file mode 100644 index 00000000000..5ad3ef0d7b2 --- /dev/null +++ b/netbox/templates/tenancy/package.html @@ -0,0 +1,104 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + {% if perms.tenancy.change_package %} + + + Edit this package + + {% endif %} + {% if perms.tenancy.delete_package %} + + + Delete this package + + {% endif %} +
    +

    {% block title %}{{ package }}{% endblock %}

    +{% include 'inc/created_updated.html' with obj=package %} +
    +
    +
    +
    + Package +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Group + {% if package.group %} + {{ package.group }} + {% else %} + None + {% endif %} +
    IPv4{{ package.ipv4_enabled }}
    IPv6{{ package.ipv6_enabled }}
    Multicast{{ package.multicast_enabled }}
    Service type{{ package.service_type }}
    Upload speed{{ package.speed_upload }}
    Download speed{{ package.speed_download }}
    QoS Profile{{ package.qos_profile }}
    DHCP Pool{{ package.dhcp_pool }}
    Tag type{{ package.tag_type }}
    +
    + {% with tenant.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} +
    +
    +{% endblock %} diff --git a/netbox/templates/tenancy/package_edit.html b/netbox/templates/tenancy/package_edit.html new file mode 100644 index 00000000000..aad274a8ea5 --- /dev/null +++ b/netbox/templates/tenancy/package_edit.html @@ -0,0 +1,58 @@ +{% extends 'utilities/obj_edit.html' %} +{% load static from staticfiles %} +{% load form_helpers %} + +{% block form %} +
    +
    Tenant
    +
    + {% render_field form.name %} + {% render_field form.slug %} + {% render_field form.group %} + {% render_field form.ipv4_enabled %} + {% render_field form.ipv6_enabled %} + {% render_field form.multicast_enabled %} + {% render_field form.service_type %} +
    + +
    +
    + {{ form.speed_upload }} + {% include 'tenancy/inc/speed_widget.html' with target_field='speed_upload' %} +
    + {{ form.speed_upload.help_text }} +
    +
    +
    + +
    +
    + {{ form.speed_download }} + {% include 'tenancy/inc/speed_widget.html' with target_field='speed_download' %} +
    + {{ form.speed_download.help_text }} +
    +
    + {% render_field form.qos_profile %} + {% render_field form.dhcp_pool %} + {% render_field form.tag_type %} +
    +
    + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    + {% endif %} +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/tenancy/package_list.html b/netbox/templates/tenancy/package_list.html new file mode 100644 index 00000000000..d084ee2507b --- /dev/null +++ b/netbox/templates/tenancy/package_list.html @@ -0,0 +1,21 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
    + {% if perms.tenancy.add_package %} + {% add_button 'tenancy:package_add' %} + {% import_button 'tenancy:package_import' %} + {% endif %} + {% export_button content_type %} +
    +

    {% block title %}Packages{% endblock %}

    +
    +
    + {% include 'utilities/obj_table.html' with bulk_edit_url='tenancy:package_bulk_edit' bulk_delete_url='tenancy:package_bulk_delete' %} +
    +
    + {% include 'inc/search_panel.html' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index c1919524640..85cd126ecce 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -5,7 +5,7 @@
    @@ -45,7 +45,7 @@

    {% block title %}{{ tenant }}{% endblock %}

    - Tenant + Customer
    diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html index b2c472a1c57..17727f30a10 100644 --- a/netbox/templates/tenancy/tenant_edit.html +++ b/netbox/templates/tenancy/tenant_edit.html @@ -4,7 +4,7 @@ {% block form %}
    -
    Tenant
    +
    Customer
    {% render_field form.name %} {% render_field form.slug %} diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html index e6fd61c372e..941a7e965ea 100644 --- a/netbox/templates/tenancy/tenant_list.html +++ b/netbox/templates/tenancy/tenant_list.html @@ -9,7 +9,7 @@ {% endif %} {% export_button content_type %}
    -

    {% block title %}Tenants{% endblock %}

    +

    {% block title %}Customers{% endblock %}

    {% include 'utilities/obj_table.html' with bulk_edit_url='tenancy:tenant_bulk_edit' bulk_delete_url='tenancy:tenant_bulk_delete' %} diff --git a/netbox/templates/tenancy/tenantgroup_list.html b/netbox/templates/tenancy/tenantgroup_list.html index a6259499426..e7c0f16b314 100644 --- a/netbox/templates/tenancy/tenantgroup_list.html +++ b/netbox/templates/tenancy/tenantgroup_list.html @@ -9,7 +9,7 @@ {% endif %} {% export_button content_type %}
    -

    {% block title %}Tenant Groups{% endblock %}

    +

    {% block title %}Service Providers{% endblock %}

    {% include 'utilities/obj_table.html' with bulk_delete_url='tenancy:tenantgroup_bulk_delete' %} diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html index 706591ab4a6..1969ddea207 100644 --- a/netbox/templates/virtualization/virtualmachine_edit.html +++ b/netbox/templates/virtualization/virtualmachine_edit.html @@ -34,7 +34,7 @@
    -
    Tenancy
    +
    {{ form.tenant_group.label }}/{{ form.tenant.label }}
    {% render_field form.tenant_group %} {% render_field form.tenant %} diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index a52ac2c6015..5224de86b61 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from extras.api.customfields import CustomFieldModelSerializer -from tenancy.models import Tenant, TenantGroup +from tenancy.models import Tenant, TenantGroup, Package from utilities.api import ValidatedModelSerializer @@ -51,3 +51,29 @@ class WritableTenantSerializer(CustomFieldModelSerializer): class Meta: model = Tenant fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields'] + +# +# Packages +# + +class NestedPackageSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:package-detail') + + class Meta: + model = Package + fields = ['id', 'url', 'name', 'slug'] + +class PackageSerializer(CustomFieldModelSerializer): + group = NestedPackageSerializer() + + class Meta: + model = Package + fields = ['id', 'name', 'slug', 'group', 'ipv4_enabled', 'ipv6_enabled', 'multicast_enabled', 'service_type', 'speed_upload', 'speed_download', 'qos_profile', 'dhcp_pool', 'tag_type'] + + +class WritablePackageSerializer(CustomFieldModelSerializer): + + class Meta: + model = Package + fields = ['id', 'name', 'slug', 'group', 'ipv4_enabled', 'ipv6_enabled', 'multicast_enabled', 'service_type', 'speed_upload', 'speed_download', 'qos_profile', 'dhcp_pool', 'tag_type'] + diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index a36a1ec3d15..5d192162b2a 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -22,6 +22,7 @@ def get_view_name(self): # Tenants router.register(r'tenant-groups', views.TenantGroupViewSet) router.register(r'tenants', views.TenantViewSet) +router.register(r'packages', views.PackageViewSet) app_name = 'tenancy-api' urlpatterns = router.urls diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index c1f7d990d47..7fc5ae4d9d4 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -4,7 +4,7 @@ from extras.api.views import CustomFieldModelViewSet from tenancy import filters -from tenancy.models import Tenant, TenantGroup +from tenancy.models import Tenant, TenantGroup, Package from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from . import serializers @@ -36,3 +36,14 @@ class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet): serializer_class = serializers.TenantSerializer write_serializer_class = serializers.WritableTenantSerializer filter_class = filters.TenantFilter + + +# +# Packages +# + +class PackageViewSet(WritableSerializerMixin, CustomFieldModelViewSet): + queryset = Package.objects.all() + serializer_class = serializers.PackageSerializer + write_serializer_class = serializers.WritablePackageSerializer + filter_class = filters.PackageFilter diff --git a/netbox/tenancy/constants.py b/netbox/tenancy/constants.py new file mode 100644 index 00000000000..58494b24c5d --- /dev/null +++ b/netbox/tenancy/constants.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals + +SERVICE_TYPE_STATIC = 0 +SERVICE_TYPE_DYNAMIC = 1 +SERVICE_TYPE_CHOICES = ( + (SERVICE_TYPE_STATIC, 'Static configuration'), + (SERVICE_TYPE_DYNAMIC, 'Dynamic configuration') +) + +TAG_TYPE_UNTAGGED = 0 +TAG_TYPE_SINGLETAGGED = 1 +TAG_TYPE_DOUBLETAGGED = 2 +TAG_TYPE_CHOICES = ( + (TAG_TYPE_UNTAGGED, 'Untagged port'), + (TAG_TYPE_SINGLETAGGED, 'Single tagged port'), + (TAG_TYPE_DOUBLETAGGED, 'Double tagged port') +) diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 330ab7f56f8..a71194e486d 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -5,7 +5,7 @@ from extras.filters import CustomFieldFilterSet from utilities.filters import NumericInFilter -from .models import Tenant, TenantGroup +from .models import Tenant, TenantGroup, Package class TenantGroupFilter(django_filters.FilterSet): @@ -44,3 +44,31 @@ def search(self, queryset, name, value): Q(description__icontains=value) | Q(comments__icontains=value) ) + +class PackageFilter(CustomFieldFilterSet, django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') + q = django_filters.CharFilter( + method='search', + label='Search', + ) + group_id = django_filters.ModelMultipleChoiceFilter( + queryset=TenantGroup.objects.all(), + label='Group (ID)', + ) + group = django_filters.ModelMultipleChoiceFilter( + name='group__slug', + queryset=TenantGroup.objects.all(), + to_field_name='slug', + label='Group (slug)', + ) + + class Meta: + model = Package + fields = ['name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) + ) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 4ea6c57ba7e..99a87a77f90 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -5,10 +5,11 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from utilities.forms import ( - APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, SlugField, + APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SlugField, add_blank_choice ) -from .models import Tenant, TenantGroup - +from ipam.models import Prefix +from .models import Tenant, TenantGroup, Package +from .constants import SERVICE_TYPE_CHOICES, TAG_TYPE_CHOICES # # Tenant groups @@ -22,6 +23,7 @@ class Meta: fields = ['name', 'slug'] + class TenantGroupCSVForm(forms.ModelForm): slug = SlugField() @@ -53,7 +55,8 @@ class TenantCSVForm(forms.ModelForm): required=False, to_field_name='name', help_text='Name of parent group', - error_messages={ + + error_messages={ 'invalid_choice': 'Group not found.' } ) @@ -95,7 +98,8 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): required=False, widget=forms.Select( attrs={'filter-for': 'tenant', 'nullable': 'true'} - ) + ), + label="Service Provider" ) tenant = ChainedModelChoiceField( queryset=Tenant.objects.all(), @@ -105,7 +109,8 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): required=False, widget=APISelect( api_url='/api/tenancy/tenants/?group_id={{tenant_group}}' - ) + ), + label="Customer" ) def __init__(self, *args, **kwargs): @@ -118,3 +123,89 @@ def __init__(self, *args, **kwargs): kwargs['initial'] = initial super(TenancyForm, self).__init__(*args, **kwargs) + +# +# Packages +# + +class PackageForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + slug = SlugField() + tenant_group = forms.ModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=forms.Select( + attrs={'filter-for': 'dhcp_pool', 'nullable': 'false'} + ) + ) + dhcp_pool = ChainedModelChoiceField( + queryset=Prefix.objects.all(), + chains=( + ('tenant', 'tenant_group'), + ), + required=False, + widget=APISelect( + api_url='/api/ipam/prefixes/?group_id={{tenant_group}}&is_pool=true' + ) + ) + + class Meta: + model = Package + fields = ['name', 'slug', 'group', 'ipv4_enabled', 'ipv6_enabled', 'multicast_enabled', 'service_type', 'speed_upload', 'speed_download', 'qos_profile', 'dhcp_pool', 'tag_type'] + + +class PackageFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Package + q = forms.CharField(required=False, label='Search') + tenant = FilterChoiceField( + queryset=TenantGroup.objects.annotate(filter_count=Count('packages')), + to_field_name='slug', + null_label='-- None --' + ) + + +class PackageCSVForm(forms.ModelForm): + slug = SlugField() + group = forms.ModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Name of reseller', + error_messages={ + 'invalid_choice': 'Group not found.' + } + ) + service_type = CSVChoiceField( + choices=SERVICE_TYPE_CHOICES, + help_text='Service type' + ) + tag_type = CSVChoiceField( + choices=TAG_TYPE_CHOICES, + help_text='Service type' + ) + dhcp_pool = forms.ModelChoiceField( + queryset=Prefix.objects.all(), + required=False, + help_text='Prefix used for DHCP', + to_field_name='prefix', + error_messages={ + 'invalid_choice': 'Prefix not found.' + } + ) + + class Meta: + model = Package + fields = Package.csv_headers + help_texts = { + 'name': 'Package name' + } + +class PackageBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput) + group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False) + qos_profile = forms.CharField(max_length=100, required=False) + tag_type = forms.ChoiceField(choices=add_blank_choice(TAG_TYPE_CHOICES), required=False) + service_type = forms.ChoiceField(choices=add_blank_choice(SERVICE_TYPE_CHOICES), required=False) + + class Meta: + nullable_fields = [] + diff --git a/netbox/tenancy/migrations/0004_package_model.py b/netbox/tenancy/migrations/0004_package_model.py new file mode 100644 index 00000000000..ecd19420597 --- /dev/null +++ b/netbox/tenancy/migrations/0004_package_model.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.10 on 2018-02-23 13:22 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import extras.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0023_package_model'), + ('tenancy', '0003_unicode_literals'), + ] + + operations = [ + migrations.CreateModel( + name='Package', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=30, unique=True)), + ('slug', models.SlugField(unique=True)), + ('ipv4_enabled', models.BooleanField(default=True, help_text='Customers recieve an IPv4 address', verbose_name='IPv4 is enabled')), + ('ipv6_enabled', models.BooleanField(default=True, help_text='Customers recieve an IPv6 address', verbose_name='IPv6 is enabled')), + ('multicast_enabled', models.BooleanField(default=True, help_text='Customers can use multicast', verbose_name='Multicast is enabled')), + ('service_type', models.PositiveSmallIntegerField(choices=[(0, 'Static configuration'), (1, 'Dynamic configuration')], default=1, help_text='Static or dynamic configuration', verbose_name='Service type')), + ('speed_upload', models.PositiveIntegerField(verbose_name='Upload speed rate (Kbps)')), + ('speed_download', models.PositiveIntegerField(verbose_name='Download speed rate (Kbps)')), + ('qos_profile', models.CharField(max_length=30)), + ('tag_type', models.PositiveSmallIntegerField(choices=[(0, 'Untagged port'), (1, 'Single tagged port'), (2, 'Double tagged port')], default=2, help_text='Customers provide any VLAN tags', verbose_name='Tag type')), + ('dhcp_pool', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Prefix')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='packages', to='tenancy.TenantGroup')), + ], + options={ + 'ordering': ['group', 'name'], + }, + bases=(models.Model, extras.models.CustomFieldModel), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 1fea2ceaf28..30764c9e885 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -7,6 +7,7 @@ from extras.models import CustomFieldModel, CustomFieldValue from utilities.models import CreatedUpdatedModel +from .constants import * @python_2_unicode_compatible @@ -21,6 +22,8 @@ class TenantGroup(models.Model): class Meta: ordering = ['name'] + verbose_name = 'Service Provider' + verbose_name_plural = 'Service Providers' def __str__(self): return self.name @@ -52,6 +55,9 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel): class Meta: ordering = ['group', 'name'] + verbose_name = 'Customer' + verbose_name_plural = 'Customers' + def __str__(self): return self.name @@ -67,3 +73,51 @@ def to_csv(self): self.description, self.comments, ) + +@python_2_unicode_compatible +class Package(CreatedUpdatedModel, CustomFieldModel): + """ + A Package represents a service delivered to our customers. + """ + name = models.CharField(max_length=30, unique=True) + slug = models.SlugField(unique=True) + group = models.ForeignKey('TenantGroup', related_name='packages', blank=False, null=False, on_delete=models.CASCADE) + ipv4_enabled = models.BooleanField(blank=False, default=True, verbose_name='IPv4 is enabled', help_text='Customers recieve an IPv4 address') + ipv6_enabled = models.BooleanField(blank=False, default=True, verbose_name='IPv6 is enabled', help_text='Customers recieve an IPv6 address') + multicast_enabled = models.BooleanField(blank=False, default=True, verbose_name='Multicast is enabled', help_text='Customers can use multicast') + service_type = models.PositiveSmallIntegerField('Service type', choices=SERVICE_TYPE_CHOICES, default=SERVICE_TYPE_DYNAMIC, help_text="Static or dynamic configuration") + speed_upload = models.PositiveIntegerField(blank=False, null=False, verbose_name='Upload speed rate (Kbps)') + speed_download = models.PositiveIntegerField(blank=False, null=False, verbose_name='Download speed rate (Kbps)') + qos_profile = models.CharField(max_length=30, unique=False) + dhcp_pool = models.ForeignKey('ipam.Prefix', related_name='prefixes', blank=True, null=True, on_delete=models.SET_NULL) + tag_type = models.PositiveSmallIntegerField('Tag type', choices=TAG_TYPE_CHOICES, default=TAG_TYPE_DOUBLETAGGED, help_text="Customers provide any VLAN tags") + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + + csv_headers = ['name', 'slug', 'group', 'ipv4_enabled', 'ipv6_enabled', 'multicast_enabled', 'service_type', 'speed_upload', 'speed_download', 'qos_profile', 'dhcp_pool', 'tag_type'] + + class Meta: + ordering = ['group', 'name'] + verbose_name = 'Package' + verbose_name_plural = 'Packages' + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('tenancy:package', args=[self.slug]) + + def to_csv(self): + return ( + self.name, + self.slug, + self.group.name if self.group else None, + self.ipv4_enabled, + self.ipv6_enabled, + self.multicast_enabled, + self.service_type, + self.speed_upload, + self.speed_download, + self.qos_profile, + self.dhcp_pool.prefix if self.dhcp_pool else None, + self.tag_type, + ) diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index b3c67e9e2c2..8cf9f9b0a8c 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -3,7 +3,7 @@ import django_tables2 as tables from utilities.tables import BaseTable, ToggleColumn -from .models import Tenant, TenantGroup +from .models import Tenant, TenantGroup, Package TENANTGROUP_ACTIONS = """ {% if perms.tenancy.change_tenantgroup %} @@ -19,6 +19,12 @@ {% endif %} """ +PACKAGE_ACTIONS = """ +{% if perms.tenancy.change_package %} + +{% endif %} +""" + # # Tenant groups @@ -49,3 +55,18 @@ class TenantTable(BaseTable): class Meta(BaseTable.Meta): model = Tenant fields = ('pk', 'name', 'group', 'description') + +# +# Packages +# + +class PackageTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + actions = tables.TemplateColumn( + template_code=PACKAGE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = Package + fields = ('pk', 'name', 'group', 'ipv4_enabled', 'ipv6_enabled', 'multicast_enabled', 'service_type', 'speed_upload', 'speed_download', 'dhcp_pool') diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 668b194f0a5..39b5fd2b72c 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -8,20 +8,30 @@ urlpatterns = [ # Tenant groups - url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'), - url(r'^tenant-groups/add/$', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'), - url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), - url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), - url(r'^tenant-groups/(?P[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), + url(r'^service-providers/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'), + url(r'^service-providers/add/$', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'), + url(r'^service-providers/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), + url(r'^service-providers/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), + url(r'^service-providers/(?P[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), # Tenants - url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'), - url(r'^tenants/add/$', views.TenantCreateView.as_view(), name='tenant_add'), - url(r'^tenants/import/$', views.TenantBulkImportView.as_view(), name='tenant_import'), - url(r'^tenants/edit/$', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), - url(r'^tenants/delete/$', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), - url(r'^tenants/(?P[\w-]+)/$', views.TenantView.as_view(), name='tenant'), - url(r'^tenants/(?P[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'), - url(r'^tenants/(?P[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'), + url(r'^customers/$', views.TenantListView.as_view(), name='tenant_list'), + url(r'^customers/add/$', views.TenantCreateView.as_view(), name='tenant_add'), + url(r'^customers/import/$', views.TenantBulkImportView.as_view(), name='tenant_import'), + url(r'^customers/edit/$', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), + url(r'^customers/delete/$', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), + url(r'^customers/(?P[\w-]+)/$', views.TenantView.as_view(), name='tenant'), + url(r'^customers/(?P[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'), + url(r'^customers/(?P[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'), + + # Packages + url(r'^packages/$', views.PackageListView.as_view(), name='package_list'), + url(r'^packages/add/$', views.PackageCreateView.as_view(), name='package_add'), + url(r'^packages/import/$', views.PackageBulkImportView.as_view(), name='package_import'), + url(r'^packages/edit/$', views.PackageBulkEditView.as_view(), name='package_bulk_edit'), + url(r'^packages/delete/$', views.PackageBulkDeleteView.as_view(), name='package_bulk_delete'), + url(r'^packages/(?P[\w-]+)/$', views.PackageView.as_view(), name='package'), + url(r'^packages/(?P[\w-]+)/edit/$', views.PackageEditView.as_view(), name='package_edit'), + url(r'^packages/(?P[\w-]+)/delete/$', views.PackageDeleteView.as_view(), name='package_delete'), ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 33df6a5ca9e..5c2294d9b65 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -14,7 +14,7 @@ ) from virtualization.models import VirtualMachine from . import filters, forms, tables -from .models import Tenant, TenantGroup +from .models import Tenant, TenantGroup, Package # @@ -138,3 +138,61 @@ class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): filter = filters.TenantFilter table = tables.TenantTable default_return_url = 'tenancy:tenant_list' + +# +# Packages +# + +class PackageListView(ObjectListView): + queryset = Package.objects.annotate(package_count=Count('id')) + filter = filters.PackageFilter + filter_form = forms.PackageFilterForm + table = tables.PackageTable + template_name = 'tenancy/package_list.html' + +class PackageCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'tenancy.add_package' + model = Package + model_form = forms.PackageForm + template_name = 'tenancy/package_edit.html' + default_return_url = 'tenancy:package_list' + +class PackageEditView(PackageCreateView): + permission_required = 'tenancy.change_package' + +class PackageBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'tenancy.add_package' + model_form = forms.PackageCSVForm + table = tables.PackageTable + default_return_url = 'tenancy:package_list' + +class PackageBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'tenancy.change_package' + cls = Package + queryset = Package.objects.select_related('group') + filter = filters.PackageFilter + table = tables.PackageTable + form = forms.PackageBulkEditForm + default_return_url = 'tenancy:package_list' + +class PackageBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'tenancy.delete_package' + cls = Package + queryset = Package.objects.select_related('group') + filter = filters.PackageFilter + table = tables.PackageTable + default_return_url = 'tenancy:package_list' + +class PackageView(View): + + def get(self, request, slug): + package = get_object_or_404(Package, slug=slug) + + return render(request, 'tenancy/package.html', { + 'package': package + }) + +class PackageDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'tenancy.delete_package' + model = Package + default_return_url = 'tenancy:package_list' \ No newline at end of file diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 4ed1aecedf3..f137d352888 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -104,7 +104,7 @@ def example_choices(field, arg=3): """ examples = [] if hasattr(field, 'queryset'): - choices = [(obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1]] + choices = [(obj.pk, str(getattr(obj, field.to_field_name))) for obj in field.queryset[:arg + 1]] else: choices = field.choices for id, label in choices: