diff --git a/CHANGELOG.md b/CHANGELOG.md index 9804d26..e66d62f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ## Version 2 +### Version 2.2.4 + +* [166](https://github.com/mlebreuil/netbox-contract/issues/166) Review the Contract view to include invoice template details and lines. +* [161](https://github.com/mlebreuil/netbox-contract/issues/161) Change the invoice block title if the invoice is a template. +* [160](https://github.com/mlebreuil/netbox-contract/issues/160) Add more fields to the invoice and contract bulk edit forms. +* [165](https://github.com/mlebreuil/netbox-contract/issues/165) Fix Invoice and invoiceline creation through api. + ### Version 2.2.3 * Fix accounting dimensions access through Dynamic Object Fields diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..306d00f --- /dev/null +++ b/docs/api.md @@ -0,0 +1,6 @@ +# API + +note: When creating invoices and invoice lines through the API, the corresponding contracts respectively accounting dimensions, must be referenced as a list of id. + + + diff --git a/docs/invoice_line.md b/docs/invoice_line.md index 89a3960..fd4394c 100644 --- a/docs/invoice_line.md +++ b/docs/invoice_line.md @@ -3,3 +3,4 @@ ![Invoice line](img/invoice_line.png "invoice line") + diff --git a/pyproject.toml b/pyproject.toml index abc2099..70b1fba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "netbox-contract" -version = "2.2.3" +version = "2.2.4" authors = [ { name="Marc Lebreuil", email="marc@famillelebreuil.net" }, ] diff --git a/src/netbox_contract/__init__.py b/src/netbox_contract/__init__.py index c3228bf..c26a161 100644 --- a/src/netbox_contract/__init__.py +++ b/src/netbox_contract/__init__.py @@ -5,7 +5,7 @@ class ContractsConfig(PluginConfig): name = 'netbox_contract' verbose_name = 'Netbox contract' description = 'Contract management plugin for Netbox' - version = '2.2.3' + version = '2.2.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 7d419ce..961f963 100644 --- a/src/netbox_contract/api/serializers.py +++ b/src/netbox_contract/api/serializers.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from drf_yasg.utils import swagger_serializer_method -from netbox.api.fields import ContentTypeField +from netbox.api.fields import ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from netbox.constants import NESTED_SERIALIZER_PREFIX from rest_framework import serializers @@ -31,10 +32,46 @@ class NestedContractSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField( view_name='plugins-api:netbox_contract-api:contract-detail' ) + yrc = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True) + tenant = NestedTenantSerializer(many=False, required=False) + external_partie_object_type = ContentTypeField(queryset=ContentType.objects.all()) + external_partie_object = serializers.SerializerMethodField(read_only=True) class Meta: model = Contract - fields = ('id', 'url', 'display', 'name') + fields = fields = ( + 'id', + 'url', + 'display', + 'name', + 'external_partie_object_type', + 'external_partie_object_id', + 'external_partie_object', + 'external_reference', + 'internal_partie', + 'tenant', + 'status', + 'start_date', + 'end_date', + 'initial_term', + 'renewal_term', + 'currency', + 'accounting_dimensions', + 'mrc', + 'yrc', + 'nrc', + 'invoice_frequency', + 'comments', + ) + + @swagger_serializer_method(serializer_or_field=serializers.JSONField) + def get_external_partie_object(self, instance): + serializer = get_serializer_for_model( + instance.external_partie_object_type.model_class(), + prefix=NESTED_SERIALIZER_PREFIX, + ) + context = {'request': self.context['request']} + return serializer(instance.external_partie_object, context=context).data class NestedInvoiceSerializer(WritableNestedSerializer): @@ -134,8 +171,25 @@ class Meta: 'url', 'display', 'name', + 'external_partie_object_type', + 'external_partie_object_id', 'external_partie_object', + 'external_reference', + 'internal_partie', + 'tenant', 'status', + 'start_date', + 'end_date', + 'initial_term', + 'renewal_term', + 'currency', + 'accounting_dimensions', + 'mrc', + 'yrc', + 'nrc', + 'invoice_frequency', + 'comments', + 'parent', ) @swagger_serializer_method(serializer_or_field=serializers.JSONField) @@ -152,7 +206,12 @@ class InvoiceSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField( view_name='plugins-api:netbox_contract-api:invoice-detail' ) - contracts = NestedContractSerializer(many=True, required=False) + contracts = SerializedPKRelatedField( + queryset=Contract.objects.all(), + serializer=ContractSerializer, + required=False, + many=True, + ) class Meta: model = Invoice @@ -162,6 +221,7 @@ class Meta: 'display', 'number', 'date', + 'template', 'contracts', 'period_start', 'period_end', @@ -174,7 +234,87 @@ class Meta: 'created', 'last_updated', ) - brief_fields = ('id', 'url', 'display', 'number', 'contracts') + brief_fields = ( + 'id', + 'url', + 'display', + 'number', + 'date', + 'template', + 'contracts', + 'period_start', + 'period_end', + 'currency', + 'accounting_dimensions', + 'amount', + 'comments', + ) + + def validate(self, data): + data = super().validate(data) + + # template checks + if data['template']: + # Check that there is only one invoice template per contract + contracts = data['contracts'] + for contract in contracts: + for invoice in contract.invoices.all(): + if invoice.template and invoice != self.instance: + raise serializers.ValidationError( + 'Only one invoice template allowed per contract' + ) + + # Prefix the invoice name with _template + data['number'] = '_invoice_template_' + contract.name + + # set the periode start and end date to null + data['period_start'] = None + data['period_end'] = None + return data + + def create(self, validated_data): + instance = super().create(validated_data) + + if not instance.template: + contracts = instance.contracts.all() + + for contract in contracts: + try: + template_exists = True + invoice_template = Invoice.objects.get( + template=True, contracts=contract + ) + except ObjectDoesNotExist: + template_exists = False + + if template_exists: + first = True + for line in invoice_template.invoicelines.all(): + dimensions = line.accounting_dimensions.all() + line.pk = None + line.id = None + line._state.adding = True + line.invoice = instance + + # adjust the first invoice line amount + amount = validated_data['amount'] + if ( + first + and amount != invoice_template.total_invoicelines_amount + ): + line.amount = ( + line.amount + + amount + - invoice_template.total_invoicelines_amount + ) + + line.save() + + for dimension in dimensions: + line.accounting_dimensions.add(dimension) + first = False + + return instance class ServiceProviderSerializer(NetBoxModelSerializer): @@ -235,8 +375,11 @@ class InvoiceLineSerializer(NetBoxModelSerializer): view_name='plugins-api:netbox_contract-api:invoiceline-detail' ) invoice = NestedInvoiceSerializer(many=False, required=False) - accounting_dimensions = NestedAccountingDimensionSerializer( - many=True, required=False + accounting_dimensions = SerializedPKRelatedField( + queryset=AccountingDimension.objects.all(), + serializer=NestedAccountingDimensionSerializer, + required=False, + many=True, ) class Meta: @@ -264,6 +407,18 @@ class Meta: 'name', ) + def validate(self, data): + super().validate(data) + # check for duplicate dimensions + accounting_dimensions = data['accounting_dimensions'] + dimensions_names = [] + for dimension in accounting_dimensions: + if dimension.name in dimensions_names: + raise serializers.ValidationError('duplicate accounting dimension') + else: + dimensions_names.append(dimension.name) + return data + class AccountingDimensionSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField( diff --git a/src/netbox_contract/forms.py b/src/netbox_contract/forms.py index eea70e8..27b71e7 100644 --- a/src/netbox_contract/forms.py +++ b/src/netbox_contract/forms.py @@ -30,6 +30,7 @@ AccountingDimensionStatusChoices, Contract, ContractAssignment, + CurrencyChoices, InternalEntityChoices, Invoice, InvoiceLine, @@ -227,13 +228,17 @@ def clean_external_partie_object_id(self): class ContractBulkEditForm(NetBoxModelBulkEditForm): name = forms.CharField(max_length=100, required=False) - external_reference = forms.CharField(max_length=100, required=False) internal_partie = forms.ChoiceField(choices=InternalEntityChoices, required=False) + tenant = DynamicModelChoiceField(queryset=Tenant.objects.all(), required=False) + accounting_dimensions = Dimensions(required=False) comments = CommentField(required=False) parent = DynamicModelChoiceField(queryset=Contract.objects.all(), required=False) - nullable_fields = ('comments',) + nullable_fields = ( + 'accounting_dimensions', + 'comments', + ) model = Contract @@ -386,9 +391,23 @@ class Meta: class InvoiceBulkEditForm(NetBoxModelBulkEditForm): number = forms.CharField(max_length=100, required=True) + template = forms.BooleanField(required=False) + date = forms.DateField(required=False) contracts = DynamicModelMultipleChoiceField( queryset=Contract.objects.all(), required=False ) + period_start = forms.DateField(required=False) + period_end = forms.DateField(required=False) + currency = forms.ChoiceField(choices=CurrencyChoices, required=False) + accounting_dimensions = Dimensions(required=False) + amount = forms.DecimalField(max_digits=10, decimal_places=2, required=False) + documents = forms.URLField(required=False) + comments = CommentField() + nullable_fields = ( + 'accounting_dimensions', + 'comments', + ) + model = Invoice diff --git a/src/netbox_contract/templates/netbox_contract/contract.html b/src/netbox_contract/templates/netbox_contract/contract.html index a53c2b3..3fc75e2 100644 --- a/src/netbox_contract/templates/netbox_contract/contract.html +++ b/src/netbox_contract/templates/netbox_contract/contract.html @@ -120,6 +120,33 @@
Contract
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} + {% if invoice_template %} +
+
+ Invoice template +
+ + + + + + + + + + + + + +
Number + {{ invoice_template.number }} +
Currency{{ invoice_template.currency }}
Total amount{{ invoice_template.total_invoicelines_amount }}
+
+ Invoice template lines +
+ {% render_table invoicelines_table %} +
+ {% endif %}
@@ -130,6 +157,7 @@
Assignments
+ {% if childs_table %}
@@ -138,6 +166,7 @@
childs
+ {% endif %} {% if perms.netbox_contract.view_invoice %}
diff --git a/src/netbox_contract/templates/netbox_contract/invoice.html b/src/netbox_contract/templates/netbox_contract/invoice.html index c7dcdf0..54bdd5f 100644 --- a/src/netbox_contract/templates/netbox_contract/invoice.html +++ b/src/netbox_contract/templates/netbox_contract/invoice.html @@ -13,16 +13,16 @@
-
Invoices
+ {% if object.template %} +
Invoice template
+ {% else %} +
Invoice
+ {% endif %} - - - - diff --git a/src/netbox_contract/views.py b/src/netbox_contract/views.py index 8638f0d..4a969ca 100644 --- a/src/netbox_contract/views.py +++ b/src/netbox_contract/views.py @@ -138,20 +138,38 @@ class ContractView(generic.ObjectView): ) def get_extra_context(self, request, instance): - invoices_table = tables.InvoiceListTable(instance.invoices.all()) + invoices_table = tables.InvoiceListTable( + instance.invoices.exclude(template=True) + ) + invoices_table.columns.hide('contracts') invoices_table.configure(request) assignments_table = tables.ContractAssignmentContractTable( instance.assignments.all() ) + invoice_template = instance.invoices.filter(template=True).first() + if invoice_template: + invoicelines_table = tables.InvoiceLineListTable( + invoice_template.invoicelines.all() + ) + invoicelines_table.columns.hide('invoice') + invoicelines_table.columns.hide('currency') + invoicelines_table.configure(request) + else: + invoicelines_table = None assignments_table.configure(request) - childs_table = tables.ContractListBottomTable(instance.childs.all()) - childs_table.configure(request) + if instance.childs.all(): + childs_table = tables.ContractListBottomTable(instance.childs.all()) + childs_table.configure(request) + else: + childs_table = None hidden_fields = plugin_settings.get('hidden_contract_fields') return { 'hidden_fields': hidden_fields, 'invoices_table': invoices_table, + 'invoice_template': invoice_template, + 'invoicelines_table': invoicelines_table, 'assignments_table': assignments_table, 'childs_table': childs_table, }
Number {{ object.number }}
Template{{ object.template }}
Date {{ object.date }}