diff --git a/README.md b/README.md index c820546..5236b02 100644 --- a/README.md +++ b/README.md @@ -83,3 +83,12 @@ Add support for Netbox 3.5 which become the minimum version supported to accomod * [#60](https://github.com/mlebreuil/netbox-contract/issues/60) Update contract quick search to also filter on fields "External reference" and "Comments". * [#49](https://github.com/mlebreuil/netbox-contract/issues/49) Manage permissions. + +#### version 2.0.4 + +* Add bulk update capability for contract assignement +* [#63](https://github.com/mlebreuil/netbox-contract/issues/63) Correct an API issue on the invoice object. +* [#64](https://github.com/mlebreuil/netbox-contract/issues/64) Add hierarchy to contract; New parent field created. +* [#65](https://github.com/mlebreuil/netbox-contract/issues/65) Add end date to contact import form. +* Removed the possibility of add or modify circuits to contracts. The field becomes read only and will be removed in next major release. +* Make accounting dimensions optional. diff --git a/assignement_import.csv b/assignement_import.csv new file mode 100644 index 0000000..3d9e213 --- /dev/null +++ b/assignement_import.csv @@ -0,0 +1,2 @@ +content_type,object_id,contract +devices.device,1,3 \ No newline at end of file diff --git a/contract.csv b/contract.csv new file mode 100644 index 0000000..56dd46d --- /dev/null +++ b/contract.csv @@ -0,0 +1,2 @@ +name,external_partie,internal_partie,tenant,status,start_date,end_date,mrc,nrc,invoice_frequency +Contract10,ServiceProvider1,Nagra USA,Tenant1,Active,2023-06-01,2024-05-30,100,1000,1 diff --git a/pyproject.toml b/pyproject.toml index cdfb8d8..5618c1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "netbox-contract" -version = "2.0.3" +version = "2.0.4" authors = [ { name="Marc Lebreuil", email="marc@famillelebreuil.net" }, ] diff --git a/src/netbox_contract/__init__.py b/src/netbox_contract/__init__.py index 44b515c..66d3d74 100644 --- a/src/netbox_contract/__init__.py +++ b/src/netbox_contract/__init__.py @@ -4,7 +4,7 @@ class ContractsConfig(PluginConfig): name = 'netbox_contract' verbose_name = 'Netbox contract' description = 'Contract management plugin for Netbox' - version = '2.0.3' + version = '2.0.4' author = 'Marc Lebreuil' author_email = 'marc@famillelebreuil.net' base_url = 'contracts' diff --git a/src/netbox_contract/api/serializers.py b/src/netbox_contract/api/serializers.py index dca290a..1a9b47c 100644 --- a/src/netbox_contract/api/serializers.py +++ b/src/netbox_contract/api/serializers.py @@ -50,11 +50,12 @@ class ContractSerializer(NetBoxModelSerializer): ) circuit= NestedCircuitSerializer(many=True, required=False) external_partie = NestedServiceProviderSerializer(many=False) + parent = NestedContracSerializer(many=False, required=False) class Meta: model = Contract fields = ( - 'id', 'url','display', 'name', 'status', 'external_partie','internal_partie','circuit','comments', + 'id', 'url','display', 'name', 'status', 'external_partie','internal_partie','parent','circuit','comments', 'tags', 'custom_fields', 'created', 'last_updated', ) diff --git a/src/netbox_contract/api/views.py b/src/netbox_contract/api/views.py index 7ac1c56..9a5fd15 100644 --- a/src/netbox_contract/api/views.py +++ b/src/netbox_contract/api/views.py @@ -4,12 +4,14 @@ from .serializers import ContractSerializer, InvoiceSerializer, ServiceProviderSerializer, ContractAssignementSerializer class ContractViewSet(NetBoxModelViewSet): - queryset = models.Contract.objects.prefetch_related('circuit','tags') + queryset = models.Contract.objects.prefetch_related( + 'parent','circuit','tags' + ) serializer_class = ContractSerializer class InvoiceViewSet(NetBoxModelViewSet): queryset = models.Invoice.objects.prefetch_related( - 'contract', 'tags' + 'contracts', 'tags' ) serializer_class = InvoiceSerializer filterset_class = filtersets.InvoiceFilterSet diff --git a/src/netbox_contract/filtersets.py b/src/netbox_contract/filtersets.py index 2cfe177..dbfb748 100644 --- a/src/netbox_contract/filtersets.py +++ b/src/netbox_contract/filtersets.py @@ -7,7 +7,7 @@ class ContractFilterSet(NetBoxModelFilterSet): class Meta: model = Contract - fields = ('id', 'external_partie', 'internal_partie', 'status','circuit') + fields = ('id', 'external_partie', 'internal_partie', 'status','parent','circuit') def search(self, queryset, name, value): return queryset.filter( Q(name__icontains=value) diff --git a/src/netbox_contract/forms.py b/src/netbox_contract/forms.py index 796c1ca..594f0e9 100644 --- a/src/netbox_contract/forms.py +++ b/src/netbox_contract/forms.py @@ -1,27 +1,33 @@ from django import forms +from django.contrib.contenttypes.models import ContentType import django_filters from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelBulkEditForm, NetBoxModelImportForm -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, MultipleChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, MultipleChoiceField, CSVModelChoiceField, SlugField, CSVContentTypeField from utilities.forms.widgets import DatePicker from extras.filters import TagFilter from circuits.models import Circuit +from tenancy.models import Tenant from .models import Contract, Invoice, ServiceProvider, StatusChoices, ContractAssignement class ContractForm(NetBoxModelForm): comments = CommentField() - circuit=DynamicModelMultipleChoiceField( - queryset=Circuit.objects.all(), - required=False - ) + external_partie=DynamicModelChoiceField( queryset=ServiceProvider.objects.all() ) + tenant=DynamicModelChoiceField( + queryset=Tenant.objects.all() + ) + parent=DynamicModelChoiceField( + queryset=Contract.objects.all(), + required=False + ) class Meta: model = Contract fields = ('name', 'external_partie', 'external_reference', 'internal_partie','tenant', 'status', 'start_date', 'end_date','initial_term', 'renewal_term', 'currency','accounting_dimensions', - 'mrc', 'nrc','invoice_frequency','circuit', 'documents', 'comments', 'tags') + 'mrc', 'nrc','invoice_frequency','parent','documents', 'comments', 'tags') widgets = { 'start_date': DatePicker(), @@ -46,10 +52,14 @@ class Meta: class ContractFilterSetForm(NetBoxModelFilterSetForm): model = Contract - external_partie=DynamicModelMultipleChoiceField( + external_partie=DynamicModelChoiceField( queryset=ServiceProvider.objects.all(), required=False ) + tenant=DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) external_reference=forms.CharField( required=False ) @@ -64,6 +74,10 @@ class ContractFilterSetForm(NetBoxModelFilterSetForm): queryset=Circuit.objects.all(), required=False ) + parent=DynamicModelChoiceField( + queryset=Contract.objects.all(), + required=False + ) class InvoiceFilterSetForm(NetBoxModelFilterSetForm): model = Invoice @@ -73,18 +87,30 @@ class InvoiceFilterSetForm(NetBoxModelFilterSetForm): ) class ContractCSVForm(NetBoxModelImportForm): - circuit = CSVModelChoiceField( - queryset=Circuit.objects.all(), + external_partie = CSVModelChoiceField( + queryset=ServiceProvider.objects.all(), + to_field_name='name', + help_text='Service provider name' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + to_field_name='name', + help_text='Tenant name', + required=False + ) + parent = CSVModelChoiceField( + queryset=Contract.objects.all(), to_field_name='name', - help_text='Related Circuit' + help_text='Contract name', + required=False ) class Meta: model = Contract fields = [ 'name', 'external_partie', 'internal_partie','tenant', 'status', - 'start_date', 'initial_term', 'renewal_term', 'mrc', 'nrc', - 'invoice_frequency', 'circuit' + 'start_date', 'end_date','initial_term', 'renewal_term', 'mrc', 'nrc', + 'invoice_frequency', 'parent' ] class ContractBulkEditForm(NetBoxModelBulkEditForm): @@ -92,9 +118,9 @@ class ContractBulkEditForm(NetBoxModelBulkEditForm): max_length=100, required=True ) - external_partie = forms.CharField( - max_length=30, - required=True + external_partie = DynamicModelChoiceField( + queryset=ServiceProvider.objects.all(), + required=False ) external_reference=forms.CharField( max_length=100, @@ -106,7 +132,12 @@ class ContractBulkEditForm(NetBoxModelBulkEditForm): ) comments = CommentField() circuit=DynamicModelChoiceField( - queryset=Circuit.objects.all() + queryset=Circuit.objects.all(), + required=False + ) + parent = DynamicModelChoiceField( + queryset=Contract.objects.all(), + required=False ) nullable_fields = ( @@ -181,6 +212,7 @@ class ContractAssignementForm(NetBoxModelForm): contract=DynamicModelChoiceField( queryset=Contract.objects.all() ) + class Meta: model = ContractAssignement fields = ['content_type', 'object_id', 'contract','tags'] @@ -194,3 +226,16 @@ class ContractAssignementFilterSetForm(NetBoxModelFilterSetForm): contract=DynamicModelChoiceField( queryset=Contract.objects.all() ) + +class ContractAssignementImportForm(NetBoxModelImportForm): + content_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + help_text="Content Type in the form ." + ) + contract = CSVModelChoiceField( + queryset=Contract.objects.all(), + help_text="Contract id" + ) + class Meta: + model = ContractAssignement + fields = ['content_type', 'object_id', 'contract','tags'] diff --git a/src/netbox_contract/migrations/0016_contract_parent.py b/src/netbox_contract/migrations/0016_contract_parent.py new file mode 100644 index 0000000..eead24f --- /dev/null +++ b/src/netbox_contract/migrations/0016_contract_parent.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.9 on 2023-06-18 14:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_contract", "0015_contractassignement"), + ] + + operations = [ + migrations.AddField( + model_name="contract", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="child", + to="netbox_contract.contract", + ), + ), + ] diff --git a/src/netbox_contract/migrations/0017_alter_contract_accounting_dimensions.py b/src/netbox_contract/migrations/0017_alter_contract_accounting_dimensions.py new file mode 100644 index 0000000..d99ae60 --- /dev/null +++ b/src/netbox_contract/migrations/0017_alter_contract_accounting_dimensions.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.9 on 2023-06-18 16:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_contract", "0016_contract_parent"), + ] + + operations = [ + migrations.AlterField( + model_name="contract", + name="accounting_dimensions", + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/src/netbox_contract/models.py b/src/netbox_contract/models.py index 6254d51..33e0f2a 100644 --- a/src/netbox_contract/models.py +++ b/src/netbox_contract/models.py @@ -145,7 +145,8 @@ class Contract(NetBoxModel): default=CurrencyChoices.CURRENCY_USD ) accounting_dimensions = models.JSONField( - null=True + null=True, + blank=True ) mrc = models.DecimalField( verbose_name = "Monthly recuring cost", @@ -164,7 +165,7 @@ class Contract(NetBoxModel): ) circuit = models.ManyToManyField(Circuit, related_name='contracts', - blank=True, + blank=True ) documents = models.URLField( blank=True @@ -172,6 +173,13 @@ class Contract(NetBoxModel): comments = models.TextField( blank=True ) + parent = models.ForeignKey( + 'self', + on_delete=models.CASCADE, + related_name='child', + null=True, + blank=True + ) def get_absolute_url(self): return reverse('plugins:netbox_contract:contract', args=[self.pk]) diff --git a/src/netbox_contract/navigation.py b/src/netbox_contract/navigation.py index 6cef3c8..0a468e6 100644 --- a/src/netbox_contract/navigation.py +++ b/src/netbox_contract/navigation.py @@ -54,8 +54,13 @@ buttons=serviceprovider_buttons, permissions=['netbox_contract.view_serviceprovider'] ) +contract_assignemnt_menu_item = PluginMenuItem( + link='plugins:netbox_contract:contractassignement_list', + link_text='Contract assignements', + permissions=['netbox_contract.view_contractassignement'] + ) -items = (contract_menu_item,invoices_menu_item,service_provider_menu_item) +items = (contract_menu_item,invoices_menu_item,service_provider_menu_item, contract_assignemnt_menu_item) if plugin_settings.get("top_level_menu"): menu = PluginMenu( diff --git a/src/netbox_contract/tables.py b/src/netbox_contract/tables.py index ab9b899..397f1c9 100644 --- a/src/netbox_contract/tables.py +++ b/src/netbox_contract/tables.py @@ -63,13 +63,16 @@ class ContractListTable(NetBoxTable): linkify=True ) circuit = tables.ManyToManyColumn() + parent = tables.Column( + linkify=True + ) class Meta(NetBoxTable.Meta): model = Contract fields = ('pk', 'id', 'name', 'circuit', 'external_partie', 'external_reference','internal_partie', 'status', 'mrc', - 'comments', 'actions') - default_columns = ('name', 'status', 'circuit') + 'parent','comments', 'actions') + default_columns = ('name', 'status', 'parent','circuit') class ContractListBottomTable(NetBoxTable): name = tables.Column( diff --git a/src/netbox_contract/templates/netbox_contract/contract.html b/src/netbox_contract/templates/netbox_contract/contract.html index 708b560..19981bd 100644 --- a/src/netbox_contract/templates/netbox_contract/contract.html +++ b/src/netbox_contract/templates/netbox_contract/contract.html @@ -73,6 +73,12 @@
Contract
Invoice frequency {{ object.invoice_frequency }} + + Parent + + {{ object.parent.name }} + + {% if object.documents %} Documents diff --git a/src/netbox_contract/urls.py b/src/netbox_contract/urls.py index 83bb29a..03c6afd 100644 --- a/src/netbox_contract/urls.py +++ b/src/netbox_contract/urls.py @@ -45,6 +45,7 @@ # Contract assignements path('assignements/', views.ContractAssignementListView.as_view(), name='contractassignement_list'), path('assignements/add/', views.ContractAssignementEditView.as_view(), name='contractassignement_add'), + path('assignements/import/', views.ContractAssignementBulkImportView.as_view(), name='contractassignement_import'), path('assignements//', views.ContractAssignementView.as_view(), name='contractassignement'), path('assignements//edit/', views.ContractAssignementEditView.as_view(), name='contractassignement_edit'), path('assignements//delete/', views.ContractAssignementDeleteView.as_view(), name='contractassignement_delete'), diff --git a/src/netbox_contract/views.py b/src/netbox_contract/views.py index b0dd3a6..05f6fc5 100644 --- a/src/netbox_contract/views.py +++ b/src/netbox_contract/views.py @@ -60,7 +60,7 @@ class ContractAssignementEditView(generic.ObjectEditView): form = forms.ContractAssignementForm def alter_object(self, instance, request, args, kwargs): - if not instance.pk: + if not instance.pk and kwargs: # Assign the object based on URL kwargs content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type')) instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) @@ -75,6 +75,11 @@ def get_extra_addanother_params(self, request): class ContractAssignementDeleteView(generic.ObjectDeleteView): queryset = models.ContractAssignement.objects.all() +class ContractAssignementBulkImportView(generic.BulkImportView): + queryset = models.ContractAssignement.objects.all() + model_form = forms.ContractAssignementImportForm + table = tables.ContractAssignementListTable + # Contract views class ContractView(generic.ObjectView):