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 @@
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
+ {% if invoice_template %}
+
+
+
+
+ Number |
+
+ {{ invoice_template.number }}
+ |
+
+
+ Currency |
+ {{ invoice_template.currency }} |
+
+
+ Total amount |
+ {{ invoice_template.total_invoicelines_amount }} |
+
+
+
+ {% render_table invoicelines_table %}
+
+ {% endif %}
@@ -130,6 +157,7 @@
+ {% if childs_table %}
+ {% 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 @@
-
+ {% if object.template %}
+
+ {% else %}
+
+ {% endif %}
Number |
{{ object.number }} |
-
- Template |
- {{ object.template }} |
-
Date |
{{ object.date }} |
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,
}