diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b4fb13..c73efcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ## Version 2 +### Version 2.2.0 + +* [140](https://github.com/mlebreuil/netbox-contract/issues/140) Add the "Invoice line" and "Accounting dimension" models. In order to simplify invoices creation, it is possible to selsct one invoice as the template for each contract; Its accounting lines will automatically be copied to the new invoices for the contract. The amount of the first line will be updated so that the sum of the amount for each invoice line match the invoice amount. + ### Version 2.1.2 * [127](https://github.com/mlebreuil/netbox-contract/issues/135) Fix service provider creation issue diff --git a/docs/accounting_dimensions.md b/docs/accounting_dimensions.md new file mode 100644 index 0000000..09b5d7f --- /dev/null +++ b/docs/accounting_dimensions.md @@ -0,0 +1,5 @@ +# Accounting dimensions + +![Accounting dimensions](img/accrounting_dimensions.png "accounting dimensions") + + diff --git a/docs/contract.md b/docs/contract.md new file mode 100644 index 0000000..c36f693 --- /dev/null +++ b/docs/contract.md @@ -0,0 +1,7 @@ +# Contract + +![Contract](img/contract.png "contract") + +Linked objects: + +![Contract linked objects](img/contract_linked_objects.png "contract linked objects") diff --git a/docs/img/accounting_dimensions.png b/docs/img/accounting_dimensions.png new file mode 100644 index 0000000..d7504e6 Binary files /dev/null and b/docs/img/accounting_dimensions.png differ diff --git a/docs/img/contract.png b/docs/img/contract.png new file mode 100644 index 0000000..16f8125 Binary files /dev/null and b/docs/img/contract.png differ diff --git a/docs/img/contract_linked_objects.png b/docs/img/contract_linked_objects.png new file mode 100644 index 0000000..9a55fd2 Binary files /dev/null and b/docs/img/contract_linked_objects.png differ diff --git a/docs/img/invoice.png b/docs/img/invoice.png new file mode 100644 index 0000000..ab20101 Binary files /dev/null and b/docs/img/invoice.png differ diff --git a/docs/img/invoice_line.png b/docs/img/invoice_line.png new file mode 100644 index 0000000..8b7724c Binary files /dev/null and b/docs/img/invoice_line.png differ diff --git a/docs/img/invoice_linked_objects.png b/docs/img/invoice_linked_objects.png new file mode 100644 index 0000000..c150349 Binary files /dev/null and b/docs/img/invoice_linked_objects.png differ diff --git a/docs/invoice.md b/docs/invoice.md new file mode 100644 index 0000000..21ca721 --- /dev/null +++ b/docs/invoice.md @@ -0,0 +1,7 @@ +# Invoice + +![Invoice](img/invoice.png "invoice") + +Linked objects: + +![Invoice linked objects](img/invoice_linked_objects.png "invoice linked objects") diff --git a/docs/invoice_line.md b/docs/invoice_line.md new file mode 100644 index 0000000..89a3960 --- /dev/null +++ b/docs/invoice_line.md @@ -0,0 +1,5 @@ +# Invoice line + +![Invoice line](img/invoice_line.png "invoice line") + + diff --git a/mkdocs.yml b/mkdocs.yml index d5c8af6..d916d2c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,6 +4,10 @@ repo_url: https://github.com/mlebreuil/netbox-contract repo_name: netbox-contract nav: - Home: index.md + - Contract: contract.md + - Invoice: invoice.md + - Invoice line: invoice_line.md + - Accounting dimensions: accounting_dimensions.md - Contributing: contributing.md - Changelog: changelog.md theme: diff --git a/pyproject.toml b/pyproject.toml index a3c256b..746b688 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "netbox-contract" -version = "2.1.3" +version = "2.2.0" authors = [ { name="Marc Lebreuil", email="marc@famillelebreuil.net" }, ] diff --git a/src/netbox_contract/__init__.py b/src/netbox_contract/__init__.py index ce920fa..581a217 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.1.3' + version = '2.2.0' 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 0aea2b3..ea2d905 100644 --- a/src/netbox_contract/api/serializers.py +++ b/src/netbox_contract/api/serializers.py @@ -7,7 +7,14 @@ from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model -from ..models import Contract, ContractAssignment, Invoice, ServiceProvider +from ..models import ( + AccountingDimension, + Contract, + ContractAssignment, + Invoice, + InvoiceLine, + ServiceProvider, +) class NestedServiceProviderSerializer(WritableNestedSerializer): @@ -52,6 +59,28 @@ class Meta: brief_fields = ('id', 'url', 'display', 'contract', 'content_object') +class NestedInvoicelineSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_contract-api:InvoiceLine-detail' + ) + + class Meta: + model = InvoiceLine + fields = ('id', 'url', 'display', 'invoice', 'amount') + brief_fields = ('id', 'url', 'display', 'invoice', 'amount') + + +class NestedAccountingDimensionSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_contract-api:AccountingDimension-detail' + ) + + class Meta: + model = AccountingDimension + fields = ('id', 'url', 'display', 'name', 'value') + brief_fields = ('id', 'url', 'display', 'name', 'value') + + class ContractSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField( view_name='plugins-api:netbox_contract-api:contract-detail' @@ -192,3 +221,48 @@ def get_content_object(self, instance): ) context = {'request': self.context['request']} return serializer(instance.content_object, context=context).data + + +class InvoiceLineSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_contract-api:invoiceline-detail' + ) + + class Meta: + model = InvoiceLine + fields = ( + 'id', + 'url', + 'display', + 'invoice', + 'amount', + 'currency', + 'comments', + 'tags', + 'custom_fields', + 'created', + 'last_updated', + ) + brief_fields = ('invoice', 'amount', 'url', 'display', 'name') + + +class AccountingDimensionSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_contract-api:accountingdimension-detail' + ) + + class Meta: + model = AccountingDimension + fields = ( + 'id', + 'url', + 'display', + 'name', + 'value', + 'comments', + 'tags', + 'custom_fields', + 'created', + 'last_updated', + ) + brief_fields = ('name', 'value', 'url', 'display') diff --git a/src/netbox_contract/api/urls.py b/src/netbox_contract/api/urls.py index e3506c7..8c3502a 100644 --- a/src/netbox_contract/api/urls.py +++ b/src/netbox_contract/api/urls.py @@ -9,5 +9,7 @@ router.register('invoices', views.InvoiceViewSet) router.register('serviceproviders', views.ServiceProviderViewSet) router.register('contractassignment', views.ContractAssignmentViewSet) +router.register('invoiceline', views.InvoiceLineViewSet) +router.register('accountingdimension', views.AccountingDimensionViewSet) urlpatterns = router.urls diff --git a/src/netbox_contract/api/views.py b/src/netbox_contract/api/views.py index 2114dc2..d4d2edb 100644 --- a/src/netbox_contract/api/views.py +++ b/src/netbox_contract/api/views.py @@ -3,8 +3,10 @@ from .. import filtersets, models from .serializers import ( + AccountingDimensionSerializer, ContractAssignmentSerializer, ContractSerializer, + InvoiceLineSerializer, InvoiceSerializer, ServiceProviderSerializer, ) @@ -31,3 +33,13 @@ class ServiceProviderViewSet(NetBoxModelViewSet): class ContractAssignmentViewSet(NetBoxModelViewSet): queryset = models.ContractAssignment.objects.prefetch_related('contract', 'tags') serializer_class = ContractAssignmentSerializer + + +class InvoiceLineViewSet(NetBoxModelViewSet): + queryset = models.InvoiceLine.objects.prefetch_related('invoice', 'tags') + serializer_class = InvoiceLineSerializer + + +class AccountingDimensionViewSet(NetBoxModelViewSet): + queryset = models.AccountingDimension.objects.prefetch_related('tags') + serializer_class = AccountingDimensionSerializer diff --git a/src/netbox_contract/filtersets.py b/src/netbox_contract/filtersets.py index 767b12b..d156483 100644 --- a/src/netbox_contract/filtersets.py +++ b/src/netbox_contract/filtersets.py @@ -1,7 +1,14 @@ from django.db.models import Q from netbox.filtersets import NetBoxModelFilterSet -from .models import Contract, ContractAssignment, Invoice, ServiceProvider +from .models import ( + AccountingDimension, + Contract, + ContractAssignment, + Invoice, + InvoiceLine, + ServiceProvider, +) class ContractFilterSet(NetBoxModelFilterSet): @@ -14,7 +21,7 @@ def search(self, queryset, name, value): Q(name__icontains=value) | Q(external_reference__icontains=value) | Q(comments__icontains=value), - Q(status__iexact='Active') + Q(status__iexact='Active'), ) @@ -25,8 +32,7 @@ class Meta: def search(self, queryset, name, value): return queryset.filter( - Q(number__icontains=value) - | Q(contracts__name__icontains=value) + Q(number__icontains=value) | Q(contracts__name__icontains=value) ) @@ -46,3 +52,25 @@ class Meta: def search(self, queryset, name, value): return queryset.filter(Q(contract__name__icontains=value)) + + +class InvoiceLineFilterSet(NetBoxModelFilterSet): + class Meta: + model = InvoiceLine + fields = ('id', 'invoice') + + def search(self, queryset, name, value): + return queryset.filter( + Q(comments__icontains=value) | Q(invoice__name__icontains=value) + ) + + +class AccountingDimensionFilterSet(NetBoxModelFilterSet): + class Meta: + model = AccountingDimension + fields = ('name', 'value') + + def search(self, queryset, name, value): + return queryset.filter( + Q(comments__icontains=value) | Q(invoice__name__icontains=value) + ) diff --git a/src/netbox_contract/forms.py b/src/netbox_contract/forms.py index 6799b6a..64fe1df 100644 --- a/src/netbox_contract/forms.py +++ b/src/netbox_contract/forms.py @@ -1,6 +1,7 @@ from django import forms from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist, ValidationError from extras.filters import TagFilter from netbox.forms import ( NetBoxModelBulkEditForm, @@ -24,10 +25,12 @@ from .constants import SERVICE_PROVIDER_MODELS from .models import ( + AccountingDimension, Contract, ContractAssignment, InternalEntityChoices, Invoice, + InvoiceLine, ServiceProvider, StatusChoices, ) @@ -46,6 +49,9 @@ def __init__(self, *args, **kwargs): self.widget.attrs['placeholder'] = str(default_dimensions) +# Contract + + class ContractForm(NetBoxModelForm): comments = CommentField() @@ -98,11 +104,6 @@ def __init__(self, *args, **kwargs): ].queryset = ServiceProvider.objects.all() self.fields['external_partie_object'].initial = None - # initialize accounting dimentsions widget - # self.fields[ - # 'accounting_dimensions' - # ].widget.attrs['placeholder'] = '{"key": "value"}' - # Initialise fields settings mandatory_fields = plugin_settings.get('mandatory_contract_fields') for field in mandatory_fields: @@ -143,46 +144,6 @@ class Meta: } -class InvoiceForm(NetBoxModelForm): - contracts = DynamicModelMultipleChoiceField( - queryset=Contract.objects.all(), required=False - ) - accounting_dimensions = Dimensions(required=False) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Initialise fields settings - mandatory_fields = plugin_settings.get('mandatory_invoice_fields') - for field in mandatory_fields: - self.fields[field].required = True - hidden_fields = plugin_settings.get('hidden_invoice_fields') - for field in hidden_fields: - if not self.fields[field].required: - self.fields[field].widget = forms.HiddenInput() - - class Meta: - model = Invoice - fields = ( - 'number', - 'date', - 'contracts', - 'period_start', - 'period_end', - 'currency', - 'accounting_dimensions', - 'amount', - 'documents', - 'comments', - 'tags', - ) - widgets = { - 'date': DatePicker(), - 'period_start': DatePicker(), - 'period_end': DatePicker(), - } - - class ContractFilterSetForm(NetBoxModelFilterSetForm): model = Contract @@ -193,13 +154,6 @@ class ContractFilterSetForm(NetBoxModelFilterSetForm): parent = DynamicModelChoiceField(queryset=Contract.objects.all(), required=False) -class InvoiceFilterSetForm(NetBoxModelFilterSetForm): - model = Invoice - contracts = DynamicModelMultipleChoiceField( - queryset=Contract.objects.all(), required=False - ) - - class ContractCSVForm(NetBoxModelImportForm): external_partie_object_type = CSVContentTypeField( queryset=ContentType.objects.all(), @@ -271,6 +225,120 @@ class ContractBulkEditForm(NetBoxModelBulkEditForm): model = Contract +# Invoice + + +class InvoiceForm(NetBoxModelForm): + contracts = DynamicModelMultipleChoiceField( + queryset=Contract.objects.all(), required=False + ) + accounting_dimensions = Dimensions(required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Initialise fields settings + mandatory_fields = plugin_settings.get('mandatory_invoice_fields') + for field in mandatory_fields: + self.fields[field].required = True + hidden_fields = plugin_settings.get('hidden_invoice_fields') + for field in hidden_fields: + if not self.fields[field].required: + self.fields[field].widget = forms.HiddenInput() + + def clean(self): + super().clean() + + # template checks + if self.cleaned_data['template']: + # Check that there is only one invoice template per contract + contracts = self.cleaned_data['contracts'] + for contract in contracts: + for invoice in contract.invoices.all(): + if invoice.template and invoice.pk != self.instance.pk: + raise ValidationError( + 'Only one invoice template allowed per contract' + ) + + # Prefix the invoice name with _template + self.cleaned_data['number'] = '_template_' + self.cleaned_data['number'] + + def save(self, *args, **kwargs): + is_new = not bool(self.instance.pk) + + instance = super().save(*args, **kwargs) + + if is_new and not self.cleaned_data['template']: + contracts = self.cleaned_data['contracts'] + + 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 = self.instance + + # adjust the first invoice line amount + amount = self.cleaned_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 Meta: + model = Invoice + fields = ( + 'number', + 'date', + 'contracts', + 'template', + 'period_start', + 'period_end', + 'currency', + 'accounting_dimensions', + 'amount', + 'documents', + 'comments', + 'tags', + ) + widgets = { + 'date': DatePicker(), + 'period_start': DatePicker(), + 'period_end': DatePicker(), + } + + +class InvoiceFilterSetForm(NetBoxModelFilterSetForm): + model = Invoice + contracts = DynamicModelMultipleChoiceField( + queryset=Contract.objects.all(), required=False + ) + + class InvoiceCSVForm(NetBoxModelImportForm): contracts = CSVModelChoiceField( queryset=Contract.objects.all(), @@ -284,6 +352,7 @@ class Meta: 'number', 'date', 'contracts', + 'template', 'period_start', 'period_end', 'currency', @@ -368,3 +437,108 @@ class ContractAssignmentImportForm(NetBoxModelImportForm): class Meta: model = ContractAssignment fields = ['content_type', 'object_id', 'contract', 'tags'] + + +# InvoiceLine + + +class InvoiceLineForm(NetBoxModelForm): + invoice = DynamicModelChoiceField(queryset=Invoice.objects.all()) + accounting_dimensions = forms.ModelMultipleChoiceField( + queryset=AccountingDimension.objects.all(), required=False + ) + + def clean(self): + super().clean() + + # check for duplicate dimensions + accounting_dimensions = self.cleaned_data['accounting_dimensions'] + dimensions_names = [] + for dimension in accounting_dimensions: + if dimension.name in dimensions_names: + raise ValidationError('duplicate accounting dimension') + else: + dimensions_names.append(dimension.name) + + class Meta: + model = InvoiceLine + fields = [ + 'invoice', + 'currency', + 'amount', + 'accounting_dimensions', + 'comments', + 'tags', + ] + + +class InvoiceLineFilterSetForm(NetBoxModelFilterSetForm): + model = InvoiceLine + invoice = DynamicModelChoiceField(queryset=Invoice.objects.all()) + accounting_dimensions = DynamicModelMultipleChoiceField( + queryset=AccountingDimension.objects.all() + ) + + +class InvoiceLineImportForm(NetBoxModelImportForm): + invoice = CSVModelChoiceField( + queryset=Invoice.objects.all(), + to_field_name='number', + help_text='Invoice number', + ) + accounting_dimensions = CSVModelChoiceField( + queryset=AccountingDimension.objects.all(), + help_text='accounting dimention in the form name, value', + ) + + class Meta: + model = InvoiceLine + fields = [ + 'invoice', + 'currency', + 'amount', + 'accounting_dimensions', + 'comments', + 'tags', + ] + + +class InvoiceLineBulkEditForm(NetBoxModelBulkEditForm): + invoice = DynamicModelChoiceField(queryset=Invoice.objects.all(), required=False) + accounting_dimensions = DynamicModelMultipleChoiceField( + queryset=AccountingDimension.objects.all(), required=False + ) + model = InvoiceLine + + +# AccountingDimension + + +class AccountingDimensionForm(NetBoxModelForm): + class Meta: + model = AccountingDimension + fields = [ + 'name', + 'value', + 'comments', + 'tags', + ] + + +class AccountingDimensionFilterSetForm(NetBoxModelFilterSetForm): + model = AccountingDimension + + +class AccountingDimensionImportForm(NetBoxModelImportForm): + class Meta: + model = AccountingDimension + fields = [ + 'name', + 'value', + 'comments', + 'tags', + ] + + +class AccountingDimensionBulkEditForm(NetBoxModelBulkEditForm): + model = AccountingDimension diff --git a/src/netbox_contract/migrations/0027_invoiceline.py b/src/netbox_contract/migrations/0027_invoiceline.py new file mode 100644 index 0000000..306ab8e --- /dev/null +++ b/src/netbox_contract/migrations/0027_invoiceline.py @@ -0,0 +1,57 @@ +# Generated by Django 5.0.6 on 2024-06-23 13:12 + +import django.db.models.deletion +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('extras', '0115_convert_dashboard_widgets'), + ('netbox_contract', '0026_auto_20240421_1550'), + ] + + operations = [ + migrations.CreateModel( + name='InvoiceLine', + 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, + ), + ), + ('currency', models.CharField(default='usd', max_length=3)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('comments', models.TextField(blank=True)), + ( + 'invoice', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='invoicelines', + to='netbox_contract.invoice', + ), + ), + ( + 'tags', + taggit.managers.TaggableManager( + through='extras.TaggedItem', to='extras.Tag' + ), + ), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/netbox_contract/migrations/0028_invoiceline_accounting_dimensions.py b/src/netbox_contract/migrations/0028_invoiceline_accounting_dimensions.py new file mode 100644 index 0000000..28f058f --- /dev/null +++ b/src/netbox_contract/migrations/0028_invoiceline_accounting_dimensions.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.6 on 2024-06-30 20:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('netbox_contract', '0027_invoiceline'), + ] + + operations = [ + migrations.AddField( + model_name='invoiceline', + name='accounting_dimensions', + field=models.JSONField(null=True), + ), + ] diff --git a/src/netbox_contract/migrations/0029_remove_invoiceline_accounting_dimensions_and_more.py b/src/netbox_contract/migrations/0029_remove_invoiceline_accounting_dimensions_and_more.py new file mode 100644 index 0000000..fa2eb1b --- /dev/null +++ b/src/netbox_contract/migrations/0029_remove_invoiceline_accounting_dimensions_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 5.0.6 on 2024-07-13 09:43 + +import taggit.managers +import utilities.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('extras', '0115_convert_dashboard_widgets'), + ('netbox_contract', '0028_invoiceline_accounting_dimensions'), + ] + + operations = [ + migrations.RemoveField( + model_name='invoiceline', + name='accounting_dimensions', + ), + migrations.CreateModel( + name='AccountingDimension', + 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, + ), + ), + ('name', models.CharField(max_length=20)), + ('value', models.CharField(max_length=20)), + ('comments', models.TextField(blank=True)), + ( + 'tags', + taggit.managers.TaggableManager( + through='extras.TaggedItem', to='extras.Tag' + ), + ), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='invoiceline', + name='accounting_dimensions', + field=models.ManyToManyField(to='netbox_contract.accountingdimension'), + ), + ] diff --git a/src/netbox_contract/migrations/0030_alter_invoiceline_accounting_dimensions.py b/src/netbox_contract/migrations/0030_alter_invoiceline_accounting_dimensions.py new file mode 100644 index 0000000..c13763a --- /dev/null +++ b/src/netbox_contract/migrations/0030_alter_invoiceline_accounting_dimensions.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.6 on 2024-07-13 12:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('netbox_contract', '0029_remove_invoiceline_accounting_dimensions_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='invoiceline', + name='accounting_dimensions', + field=models.ManyToManyField( + blank=True, + related_name='invoicelines', + to='netbox_contract.accountingdimension', + ), + ), + ] diff --git a/src/netbox_contract/migrations/0031_contract_invoice_template_invoice_template.py b/src/netbox_contract/migrations/0031_contract_invoice_template_invoice_template.py new file mode 100644 index 0000000..2e4ab2d --- /dev/null +++ b/src/netbox_contract/migrations/0031_contract_invoice_template_invoice_template.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0.6 on 2024-07-16 13:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('netbox_contract', '0030_alter_invoiceline_accounting_dimensions'), + ] + + operations = [ + migrations.AddField( + model_name='contract', + name='invoice_template', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='contract', + to='netbox_contract.invoice', + ), + ), + migrations.AddField( + model_name='invoice', + name='template', + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/src/netbox_contract/migrations/0032_alter_invoice_period_end_alter_invoice_period_start_and_more.py b/src/netbox_contract/migrations/0032_alter_invoice_period_end_alter_invoice_period_start_and_more.py new file mode 100644 index 0000000..68e2148 --- /dev/null +++ b/src/netbox_contract/migrations/0032_alter_invoice_period_end_alter_invoice_period_start_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.6 on 2024-07-23 19:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('netbox_contract', '0031_contract_invoice_template_invoice_template'), + ] + + operations = [ + migrations.AlterField( + model_name='invoice', + name='period_end', + field=models.DateField(blank=True, null=True), + ), + migrations.AlterField( + model_name='invoice', + name='period_start', + field=models.DateField(blank=True, null=True), + ), + migrations.AlterField( + model_name='invoice', + name='template', + field=models.BooleanField(blank=True, default=False, null=True), + ), + ] diff --git a/src/netbox_contract/migrations/0033_remove_contract_invoice_template.py b/src/netbox_contract/migrations/0033_remove_contract_invoice_template.py new file mode 100644 index 0000000..e3a179f --- /dev/null +++ b/src/netbox_contract/migrations/0033_remove_contract_invoice_template.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.6 on 2024-07-24 20:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ( + 'netbox_contract', + '0032_alter_invoice_period_end_alter_invoice_period_start_and_more', + ), + ] + + operations = [ + migrations.RemoveField( + model_name='contract', + name='invoice_template', + ), + ] diff --git a/src/netbox_contract/models.py b/src/netbox_contract/models.py index fc10d4c..9e58f5a 100644 --- a/src/netbox_contract/models.py +++ b/src/netbox_contract/models.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from netbox.models import NetBoxModel @@ -40,6 +41,22 @@ class CurrencyChoices(ChoiceSet): ] +class AccountingDimension(NetBoxModel): + name = models.CharField(max_length=20) + value = models.CharField(max_length=20) + comments = models.TextField(blank=True) + + def get_absolute_url(self): + return reverse('plugins:netbox_contract:accountingdimension', args=[self.pk]) + + @property + def dimension(self): + return ''.join([self.name, ':', self.value]) + + def __str__(self): + return self.dimension + + class ServiceProvider(ContactsMixin, NetBoxModel): name = models.CharField(max_length=100) slug = models.SlugField(max_length=100, unique=True) @@ -145,14 +162,15 @@ def __str__(self): class Invoice(NetBoxModel): number = models.CharField(max_length=100) + template = models.BooleanField(blank=True, null=True, default=False) date = models.DateField(blank=True, null=True) contracts = models.ManyToManyField( Contract, related_name='invoices', blank=True, ) - period_start = models.DateField() - period_end = models.DateField() + period_start = models.DateField(blank=True, null=True) + period_end = models.DateField(blank=True, null=True) currency = models.CharField( max_length=3, choices=CurrencyChoices, default=CurrencyChoices.CURRENCY_USD ) @@ -169,3 +187,47 @@ def __str__(self): def get_absolute_url(self): return reverse('plugins:netbox_contract:invoice', args=[self.pk]) + + @property + def total_invoicelines_amount(self): + """ + Calculates the total amount for all related InvoiceLines. + """ + return sum(invoiceline.amount for invoiceline in self.invoicelines.all()) + + +class InvoiceLine(NetBoxModel): + invoice = models.ForeignKey( + to='Invoice', on_delete=models.CASCADE, related_name='invoicelines' + ) + currency = models.CharField( + max_length=3, choices=CurrencyChoices, default=CurrencyChoices.CURRENCY_USD + ) + amount = models.DecimalField(max_digits=10, decimal_places=2) + accounting_dimensions = models.ManyToManyField( + AccountingDimension, related_name='invoicelines', blank=True + ) + comments = models.TextField(blank=True) + + def get_absolute_url(self): + return reverse('plugins:netbox_contract:invoiceline', args=[self.pk]) + + def clean(self): + super().clean() + # Check that the sum of the invoice line amount is not greater the invoice amount + amount = self.amount + invoice = self.invoice + is_new = not bool(self.pk) + if is_new: + if amount > (invoice.amount - invoice.total_invoicelines_amount): + raise ValidationError( + 'Sum of invoice line amount greater than invoice amount' + ) + else: + previous_amount = self.__class__.objects.get(pk=self.pk).amount + if amount > ( + invoice.amount - invoice.total_invoicelines_amount + previous_amount + ): + raise ValidationError( + 'Sum of invoice line amount greater than invoice amount' + ) diff --git a/src/netbox_contract/navigation.py b/src/netbox_contract/navigation.py index 0434a04..7f21d98 100644 --- a/src/netbox_contract/navigation.py +++ b/src/netbox_contract/navigation.py @@ -21,6 +21,24 @@ ) ] +invoiceline_buttons = [ + PluginMenuButton( + link='plugins:netbox_contract:invoiceline_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=['netbox_contract.add_invoice'], + ) +] + +accountingdimension_buttons = [ + PluginMenuButton( + link='plugins:netbox_contract:accountingdimension_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=['netbox_contract.add_invoice'], + ) +] + serviceprovider_buttons = [ PluginMenuButton( link='plugins:netbox_contract:serviceprovider_add', @@ -44,6 +62,20 @@ permissions=['netbox_contract.view_invoice'], ) +invoicelines_menu_item = PluginMenuItem( + link='plugins:netbox_contract:invoiceline_list', + link_text='Invoice lines', + buttons=invoiceline_buttons, + permissions=['netbox_contract.view_invoice'], +) + +accounting_dimensions_menu_item = PluginMenuItem( + link='plugins:netbox_contract:accountingdimension_list', + link_text='Accounting dimensions', + buttons=accountingdimension_buttons, + permissions=['netbox_contract.view_invoice'], +) + service_provider_menu_item = PluginMenuItem( link='plugins:netbox_contract:serviceprovider_list', link_text='Service Providers', @@ -59,6 +91,8 @@ items = ( contract_menu_item, invoices_menu_item, + invoicelines_menu_item, + accounting_dimensions_menu_item, service_provider_menu_item, contract_assignemnt_menu_item, ) diff --git a/src/netbox_contract/search.py b/src/netbox_contract/search.py index ae7e431..87e383b 100644 --- a/src/netbox_contract/search.py +++ b/src/netbox_contract/search.py @@ -1,6 +1,6 @@ from netbox.search import SearchIndex -from .models import Contract, Invoice, ServiceProvider +from .models import AccountingDimension, Contract, Invoice, InvoiceLine, ServiceProvider class ServiceProviderIndex(SearchIndex): @@ -26,4 +26,27 @@ class InvoiceIndex(SearchIndex): ('comments', 5000), ) -indexes = [ServiceProviderIndex,ContractIndex,InvoiceIndex] + +class InvoiceLineIndex(SearchIndex): + model = InvoiceLine + fields = ( + ('invoice', 100), + ('comments', 5000), + ) + + +class AccountingDimensionIndex(SearchIndex): + model = AccountingDimension + fields = ( + ('name', 20), + ('value', 20), + ) + + +indexes = [ + ServiceProviderIndex, + ContractIndex, + InvoiceIndex, + InvoiceLineIndex, + AccountingDimensionIndex, +] diff --git a/src/netbox_contract/tables.py b/src/netbox_contract/tables.py index e73adf3..95ea92b 100644 --- a/src/netbox_contract/tables.py +++ b/src/netbox_contract/tables.py @@ -1,7 +1,14 @@ import django_tables2 as tables from netbox.tables import NetBoxTable, columns -from .models import Contract, ContractAssignment, Invoice, ServiceProvider +from .models import ( + AccountingDimension, + Contract, + ContractAssignment, + Invoice, + InvoiceLine, + ServiceProvider, +) class ContractAssignmentListTable(NetBoxTable): @@ -188,3 +195,43 @@ class Meta(NetBoxTable.Meta): model = ServiceProvider fields = ('pk', 'name', 'slug', 'portal_url') default_columns = ('name', 'portal_url') + + +class InvoiceLineListTable(NetBoxTable): + invoice = tables.Column(linkify=True) + accounting_dimensions = tables.ManyToManyColumn(linkify=True) + + class Meta(NetBoxTable.Meta): + model = InvoiceLine + fields = ( + 'pk', + 'invoice', + 'amount', + 'currency', + 'accounting_dimensions', + 'comments', + ) + default_columns = ( + 'pk', + 'invoice', + 'amount', + 'currency', + 'accounting_dimensions', + 'comments', + ) + + +class AccountingDimensionListTable(NetBoxTable): + class Meta(NetBoxTable.Meta): + model = AccountingDimension + fields = ( + 'pk', + 'name', + 'value', + 'comments', + ) + default_columns = ( + 'name', + 'value', + 'comments', + ) diff --git a/src/netbox_contract/templates/netbox_contract/accountingdimension.html b/src/netbox_contract/templates/netbox_contract/accountingdimension.html new file mode 100644 index 0000000..d04d638 --- /dev/null +++ b/src/netbox_contract/templates/netbox_contract/accountingdimension.html @@ -0,0 +1,27 @@ +{% extends 'generic/object.html' %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block content %} +
+
+
+
Accounting dimension
+ + + + + + + + + +
name{{ object.name }}
value{{ object.value }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} +
+
+{% plugin_right_page object %} +{% endblock content %} \ No newline at end of file diff --git a/src/netbox_contract/templates/netbox_contract/invoice.html b/src/netbox_contract/templates/netbox_contract/invoice.html index c4d720f..72eed86 100644 --- a/src/netbox_contract/templates/netbox_contract/invoice.html +++ b/src/netbox_contract/templates/netbox_contract/invoice.html @@ -53,20 +53,39 @@
Invoices
{% endif %} + + Invoice lines total + {{ object.total_invoicelines_amount }} + -
-
-
-
Contracts
- {% render_table contracts_table %} -
-
-
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} +
+
+
+
+ Invoice lines + +
+ {% render_table invoicelines_table %} +
+
+
+
+
+
+
Contracts
+ {% render_table contracts_table %} +
+
+
{% plugin_right_page object %} {% endblock content %} \ No newline at end of file diff --git a/src/netbox_contract/templates/netbox_contract/invoiceline.html b/src/netbox_contract/templates/netbox_contract/invoiceline.html new file mode 100644 index 0000000..ce74359 --- /dev/null +++ b/src/netbox_contract/templates/netbox_contract/invoiceline.html @@ -0,0 +1,42 @@ +{% extends 'generic/object.html' %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} +{% block content %} +
+
+
+
Invoice Lines
+ + + + + + + + + + + + + + + + + +
Invoice + {{ object.invoice.number }} +
Amount{{ object.amount }}
Currency{{ object.currency }}
Accounting dimentions{{ object.accounting_dimensions }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} +
+
+{% plugin_right_page object %} +{% endblock content %} \ No newline at end of file diff --git a/src/netbox_contract/urls.py b/src/netbox_contract/urls.py index bf4867d..8a028c9 100644 --- a/src/netbox_contract/urls.py +++ b/src/netbox_contract/urls.py @@ -166,4 +166,78 @@ name='contractassignment_changelog', kwargs={'model': models.ContractAssignment}, ), + # InvoiceLine + path( + 'invoiceline/', + views.InvoiceLineListView.as_view(), + name='invoiceline_list', + ), + path( + 'invoiceline/add/', + views.InvoiceLineEditView.as_view(), + name='invoiceline_add', + ), + path( + 'invoiceline/import/', + views.InvoiceLineBulkImportView.as_view(), + name='invoiceline_import', + ), + path( + 'invoiceline//', + views.InvoiceLineView.as_view(), + name='invoiceline', + ), + path( + 'invoiceline//edit/', + views.InvoiceLineEditView.as_view(), + name='invoiceline_edit', + ), + path( + 'invoiceline//delete/', + views.InvoiceLineDeleteView.as_view(), + name='invoiceline_delete', + ), + path( + 'invoiceline//changelog/', + ObjectChangeLogView.as_view(), + name='invoiceline_changelog', + kwargs={'model': models.InvoiceLine}, + ), + # AccountingDimension + path( + 'accountingdimension/', + views.AccountingDimensionListView.as_view(), + name='accountingdimension_list', + ), + path( + 'accountingdimension/add/', + views.AccountingDimensionEditView.as_view(), + name='accountingdimension_add', + ), + path( + 'accountingdimension/import/', + views.AccountingDimensionBulkImportView.as_view(), + name='accountingdimension_import', + ), + path( + 'accountingdimension//', + views.AccountingDimensionView.as_view(), + name='accountingdimension', + ), + path( + 'accountingdimension//edit/', + views.AccountingDimensionEditView.as_view(), + name='accountingdimension_edit', + ), + path( + 'accountingdimension//delete/', + views.AccountingDimensionDeleteView.as_view(), + name='accountingdimension_delete', + ), + path( + 'accountingdimension//changelog/', + ObjectChangeLogView.as_view(), + name='accountingdimension_changelog', + kwargs={'model': models.AccountingDimension}, + ), ) diff --git a/src/netbox_contract/views.py b/src/netbox_contract/views.py index 12f230a..72b6acf 100644 --- a/src/netbox_contract/views.py +++ b/src/netbox_contract/views.py @@ -14,7 +14,14 @@ from utilities.views import register_model_view from . import filtersets, forms, tables -from .models import Contract, ContractAssignment, Invoice, ServiceProvider +from .models import ( + AccountingDimension, + Contract, + ContractAssignment, + Invoice, + InvoiceLine, + ServiceProvider, +) plugin_settings = settings.PLUGINS_CONFIG['netbox_contract'] @@ -218,11 +225,15 @@ class InvoiceView(generic.ObjectView): def get_extra_context(self, request, instance): contracts_table = tables.ContractListTable(instance.contracts.all()) contracts_table.configure(request) + invoicelines_table = tables.InvoiceLineListTable(instance.invoicelines.all()) + invoicelines_table.columns.hide('invoice') + invoicelines_table.configure(request) hidden_fields = plugin_settings.get('hidden_invoice_fields') return { 'hidden_fields': hidden_fields, 'contracts_table': contracts_table, + 'invoicelines_table': invoicelines_table, } @@ -313,3 +324,121 @@ class InvoiceBulkDeleteView(generic.BulkDeleteView): queryset = Invoice.objects.all() filterset = filtersets.InvoiceFilterSet table = tables.InvoiceListTable + + +# InvoiceLine + + +class InvoiceLineView(generic.ObjectView): + queryset = InvoiceLine.objects.all() + + +class InvoiceLineListView(generic.ObjectListView): + queryset = InvoiceLine.objects.all() + table = tables.InvoiceLineListTable + filterset = filtersets.InvoiceLineFilterSet + filterset_form = forms.InvoiceLineFilterSetForm + + +class InvoiceLineEditView(generic.ObjectEditView): + queryset = InvoiceLine.objects.all() + form = forms.InvoiceLineForm + + def get(self, request, *args, **kwargs): + """ + GET request handler + Overrides the ObjectEditView function to include form initialization + with data from the parent invoice object + + Args: + request: The current request + """ + obj = self.get_object(**kwargs) + obj = self.alter_object(obj, request, args, kwargs) + model = self.queryset.model + + initial_data = normalize_querydict(request.GET) + if 'invoice' in initial_data.keys(): + invoice = Invoice.objects.get(pk=initial_data['invoice']) + initial_data['amount'] = invoice.amount - invoice.total_invoicelines_amount + + form = self.form(instance=obj, initial=initial_data) + restrict_form_fields(form, request.user) + + return render( + request, + self.template_name, + { + 'model': model, + 'object': obj, + 'form': form, + 'return_url': self.get_return_url(request, obj), + 'prerequisite_model': get_prerequisite_model(self.queryset), + **self.get_extra_context(request, obj), + }, + ) + + +class InvoiceLineDeleteView(generic.ObjectDeleteView): + queryset = InvoiceLine.objects.all() + + +class InvoiceLineBulkImportView(generic.BulkImportView): + queryset = InvoiceLine.objects.all() + model_form = forms.InvoiceLineImportForm + table = tables.InvoiceLineListTable + + +class InvoiceLineBulkEditView(generic.BulkEditView): + queryset = InvoiceLine.objects.annotate() + filterset = filtersets.InvoiceLineFilterSet + table = tables.InvoiceLineListTable + form = forms.InvoiceLineBulkEditForm + + +class InvoiceLineBulkDeleteView(generic.BulkDeleteView): + queryset = InvoiceLine.objects.annotate() + filterset = filtersets.InvoiceLineFilterSet + table = tables.InvoiceLineListTable + + +# Accounting dimension + + +class AccountingDimensionView(generic.ObjectView): + queryset = AccountingDimension.objects.all() + + +class AccountingDimensionListView(generic.ObjectListView): + queryset = AccountingDimension.objects.all() + table = tables.AccountingDimensionListTable + filterset = filtersets.AccountingDimensionFilterSet + filterset_form = forms.AccountingDimensionFilterSetForm + + +class AccountingDimensionEditView(generic.ObjectEditView): + queryset = AccountingDimension.objects.all() + form = forms.AccountingDimensionForm + + +class AccountingDimensionDeleteView(generic.ObjectDeleteView): + queryset = AccountingDimension.objects.all() + + +class AccountingDimensionBulkImportView(generic.BulkImportView): + queryset = AccountingDimension.objects.all() + model_form = forms.AccountingDimensionImportForm + table = tables.AccountingDimensionListTable + + +class AccountingDimensionBulkEditView(generic.BulkEditView): + queryset = AccountingDimension.objects.annotate() + filterset = filtersets.AccountingDimensionFilterSet + table = tables.AccountingDimensionListTable + form = forms.AccountingDimensionBulkEditForm + + +class AccountingDimensionBulkDeleteView(generic.BulkDeleteView): + queryset = AccountingDimension.objects.annotate() + filterset = filtersets.AccountingDimensionFilterSet + table = tables.AccountingDimensionListTable diff --git a/utils/README.md b/utils/README.md index 55b3452..9770166 100644 --- a/utils/README.md +++ b/utils/README.md @@ -39,7 +39,7 @@ cd netbox git clone -b master --depth 1 https://github.com/netbox-community/netbox.git . ``` -You do not need to create the Betbox system user +You do not need to create the Netbox system user ### generate secret key @@ -52,20 +52,13 @@ update the netbox-contract/utils/netbox-configuration.py with this secret key ### update netbox configuration ``` -sudo cp netbox-contract/utils/netbox-configuration.py netbox/netbox/netbox/configuration.py +cp netbox-contract/utils/netbox-configuration.py netbox/netbox/netbox/configuration.py ``` ### Run the Upgrade Script ```bash -sudo netbox/upgrade.sh -``` - -Chnage the ownership of the creaed virtual environment - -```bash -cd netbox -sudo chown -R vscode:vscode venv +netbox/upgrade.sh ``` ### Create a Super User @@ -86,14 +79,10 @@ python3 netbox/netbox/manage.py runserver For development, install the plugin from the local file system: ```bash +python3 -m pip uninstall netbox-contract python3 -m pip install -e netbox-contract ``` -Update netbox configuration to configure the plugin: - -```bash -sudo cp netbox-contract/utils/netbox-configuration-final.py netbox/netbox/netbox/configuration.py -``` run database migrations: ```bash @@ -104,7 +93,7 @@ install pre-commit: ```bash cd netbox-contract -python -m pip install pre-commit +python3 -m pip install pre-commit pre-commit install ``` @@ -119,15 +108,9 @@ python3 netbox/netbox/manage.py runserver ```bash cd netbox -sudo git checkout master -sudo git pull origin master -sudo ./upgrade.sh -``` - -Change the owner of virtual environment: - -```bash -sudo chown -R vscode:vscode venv +git checkout master +git pull origin master +./upgrade.sh ``` Reinstall the pluggin from the local filesystem (see below).