From a7893999c63b173fb80d36434438dca4e7791c7d Mon Sep 17 00:00:00 2001 From: Marc Lebreuil Date: Mon, 19 Aug 2024 14:35:16 +0200 Subject: [PATCH 1/4] add script directory --- scripts/netbox-contract.py | 220 +++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 scripts/netbox-contract.py diff --git a/scripts/netbox-contract.py b/scripts/netbox-contract.py new file mode 100644 index 0000000..32723a9 --- /dev/null +++ b/scripts/netbox-contract.py @@ -0,0 +1,220 @@ +from datetime import date +from decimal import * +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Count +from extras.scripts import * +from netbox_contract.models import Contract, Invoice, InvoiceLine, AccountingDimension, StatusChoices + +name = "Netbox-contracts custom scripts" + +AMOUNT_PRECEDENCE = ( + ('invoice', 'Invoice'), + ('dimensions', 'dimensions') +) + +class update_expired_contract_status(Script): + + class Meta: + name = "Update expired contracts status" + description = "Update the status of contract with end date prior to today's date" + commit_default = False + + def run(self, data, commit): + + username = self.request.user.username + self.log_info(f"Running as user {username}") + + output = [] + + expired_contracts = Contract.objects.filter(end_date__lte = date.today()).filter(status = StatusChoices.STATUS_ACTIVE ) + + self.log_info(f"Processing Contracts") + for contract in expired_contracts: + output.append(f"{contract.name} to be updated") + contract.status = StatusChoices.STATUS_CANCELED + try: + contract.save() + self.log_info(f"{contract.name} updated") + except Exception as e: + self.log_failure(f"error processing {contract.name}: {e}") + + return '\n'.join(output) + +class create_invoice_template(Script): + + class Meta: + name = "Create invoice templates" + description = "Convert the Accounting dimensions json field in Contracts to invoice template" + commit_default = False + + def run(self, data, commit): + + username = self.request.user.username + self.log_info(f"Running as user {username}") + + output = [] + + self.log_info(f"Creating invoice templates from contract dimensions") + + # Create invoice templates for each active template + for contract in Contract.objects.filter(status = StatusChoices.STATUS_ACTIVE ): + self.log_info(f"Processing contract {contract.name}") + + # check if invoice template exist + try: + template_exists = True + invoice_template = Invoice.objects.get( + template=True, contracts=contract + ) + except ObjectDoesNotExist: + template_exists = False + + if template_exists : + self.log_info(f"Template already exists for {contract.name}") + continue + + # if the invoice template does not exists create it + if contract.accounting_dimensions: + if contract.mrc is not None: + amount = contract.mrc * contract.invoice_frequency + else: + amount = contract.yrc / 12 * contract.invoice_frequency + invoice_template = Invoice( + template = True, + number = '_invoice_template_' + contract.name, + period_start = None, + period_end = None, + amount = amount, + accounting_dimensions = contract.accounting_dimensions + ) + invoice_template.save() + invoice_template.contracts.add(contract) + self.log_info(f"Template {invoice_template.number} created for {contract.name}") + + return '\n'.join(output) + +class create_invoice_lines(Script): + + class Meta: + name = "Create invoice lines" + description = "Convert the Accounting dimensions json field in invoices to invoice lines" + commit_default = False + + ignore = StringVar( + label="Ignore", + description="Accounting dimensions to be ignored. List of string separated by comma.", + required=False, + regex=r"^\w+(,\w+)*$" + ) + + amount_precedence = ChoiceVar( + label="Amount precedence", + description="Select if the dimension amount or the invoice amount take precedence,", + choices = AMOUNT_PRECEDENCE, + required=False + ) + + line_amount_key = StringVar( + label="Line amount key", + description="Key name for line amount in the accounting dimension json with multiple lines", + required=True, + ) + + def run(self, data, commit): + + username = self.request.user.username + self.log_info(f"Running as user {username}") + + output = [] + + line_amount_key = data['line_amount_key'] + + exclude = [line_amount_key] + if data['ignore']: + exclude = exclude + data['ignore'].split(',') + + self.log_info(f"Creating invoice lines from invoices dimensions") + self.log_info(f"Ignoring dimensions {exclude}") + self.log_info(f"Line amount key {line_amount_key}") + + # import existing dimensions + dimensions={} + dims = AccountingDimension.objects.all() + if dims.exists(): + for dim in dims: + dimensions[dim.name +"_"+ dim.value] = dim + + # Get all invoices without invoice lines + invoices = Invoice.objects.annotate(numberoflines=Count("invoicelines")) + + # Create invoice lines for each invoice + for invoice in invoices: + if invoice.numberoflines > 0: + self.log_info(f"Invoice skipped {invoice.number}. Exiting lines") + continue + + self.log_info(f"Processing Invoice {invoice.number}") + + total_invoice_lines_amount = 0 + single_line_invoice = False + + # Create invoice template lines + # Check if several lines have to be created + if type(invoice.accounting_dimensions) == list: + # if the accounting dimensions is a list we assume that we have an "amount" + lines = invoice.accounting_dimensions + else: + lines = [invoice.accounting_dimensions] + + if len(lines) == 1: + single_line_invoice = True + + for line in lines: + if single_line_invoice and data['amount_precedence']=='invoice': + amount = invoice.amount + else: + if line_amount_key in line.keys(): + if isinstance(line[line_amount_key], str): + try: + amount = Decimal(line[line_amount_key].replace(",",".").replace(" ","")) + except: + self.log_warning(f"Wrong number format {line[line_amount_key]}") + output.append(f"{invoice.number}: dimensions amount format to be updated") + else: + try: + amount = Decimal(line[line_amount_key]) + except: + self.log_warning(f"Wrong number format {line[line_amount_key]}") + output.append(f"{invoice.number}: dimensions amount format to be updated") + else: + self.log_warning(f"Multiple lines or dimensions precedence and no amount for line") + continue + + invoice_line = InvoiceLine( + invoice = invoice, + currency = invoice.currency, + amount = amount, + ) + invoice_line.save() + self.log_info(f"Invoice line {invoice_line.id} created for {invoice.number}") + total_invoice_lines_amount = total_invoice_lines_amount + amount + + # create and add dimensions + for key, value in line.items(): + if key not in exclude and value is not None: + if key +"_"+ str(value) not in dimensions.keys(): + dimension = AccountingDimension( + name = key, + value = str(value) + ) + dimension.save() + dimensions[key +"_"+ str(value)] = dimension + invoice_line.accounting_dimensions.add(dimensions[key +"_"+ str(value)]) + self.log_info(f"Accounting dimensions added to Invoice line {invoice_line.id}") + + + if total_invoice_lines_amount != invoice.amount: + self.log_warning(f"The total of invoice lines and invoice amount do not match.") + output.append(f"{invoice.number}: Sum of invoice lines amount to be checked") + + return '\n'.join(output) From 00a30d512d1afe9ec9b7967cdf072c1d2b2c44b3 Mon Sep 17 00:00:00 2001 From: Marc Lebreuil Date: Tue, 20 Aug 2024 18:17:57 +0200 Subject: [PATCH 2/4] update scripts performance --- scripts/netbox-contract.py | 106 ++++++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 43 deletions(-) diff --git a/scripts/netbox-contract.py b/scripts/netbox-contract.py index 32723a9..e80ebaa 100644 --- a/scripts/netbox-contract.py +++ b/scripts/netbox-contract.py @@ -5,7 +5,7 @@ from extras.scripts import * from netbox_contract.models import Contract, Invoice, InvoiceLine, AccountingDimension, StatusChoices -name = "Netbox-contracts custom scripts" +name = "Contracts related scripts" AMOUNT_PRECEDENCE = ( ('invoice', 'Invoice'), @@ -27,16 +27,7 @@ def run(self, data, commit): output = [] expired_contracts = Contract.objects.filter(end_date__lte = date.today()).filter(status = StatusChoices.STATUS_ACTIVE ) - - self.log_info(f"Processing Contracts") - for contract in expired_contracts: - output.append(f"{contract.name} to be updated") - contract.status = StatusChoices.STATUS_CANCELED - try: - contract.save() - self.log_info(f"{contract.name} updated") - except Exception as e: - self.log_failure(f"error processing {contract.name}: {e}") + expired_contracts.update(status=StatusChoices.STATUS_CANCELED) return '\n'.join(output) @@ -61,15 +52,9 @@ def run(self, data, commit): self.log_info(f"Processing contract {contract.name}") # check if invoice template exist - try: - template_exists = True - invoice_template = Invoice.objects.get( - template=True, contracts=contract - ) - except ObjectDoesNotExist: - template_exists = False + invoice_template = Invoice.objects.filter(template=True, contracts=contract).first() - if template_exists : + if invoice_template : self.log_info(f"Template already exists for {contract.name}") continue @@ -81,7 +66,7 @@ def run(self, data, commit): amount = contract.yrc / 12 * contract.invoice_frequency invoice_template = Invoice( template = True, - number = '_invoice_template_' + contract.name, + number = f"_invoice_template_{contract.name}", period_start = None, period_end = None, amount = amount, @@ -131,7 +116,7 @@ def run(self, data, commit): exclude = [line_amount_key] if data['ignore']: - exclude = exclude + data['ignore'].split(',') + exclude.extend(data['ignore'].split(',')) self.log_info(f"Creating invoice lines from invoices dimensions") self.log_info(f"Ignoring dimensions {exclude}") @@ -142,7 +127,7 @@ def run(self, data, commit): dims = AccountingDimension.objects.all() if dims.exists(): for dim in dims: - dimensions[dim.name +"_"+ dim.value] = dim + dimensions[f"{dim.name}_{dim.value}"] = dim # Get all invoices without invoice lines invoices = Invoice.objects.annotate(numberoflines=Count("invoicelines")) @@ -156,39 +141,36 @@ def run(self, data, commit): self.log_info(f"Processing Invoice {invoice.number}") total_invoice_lines_amount = 0 - single_line_invoice = False # Create invoice template lines # Check if several lines have to be created - if type(invoice.accounting_dimensions) == list: + if isinstance(invoice.accounting_dimensions, list): # if the accounting dimensions is a list we assume that we have an "amount" lines = invoice.accounting_dimensions else: lines = [invoice.accounting_dimensions] - if len(lines) == 1: - single_line_invoice = True + single_line_invoice = len(lines) == 1 for line in lines: if single_line_invoice and data['amount_precedence']=='invoice': amount = invoice.amount else: - if line_amount_key in line.keys(): - if isinstance(line[line_amount_key], str): - try: - amount = Decimal(line[line_amount_key].replace(",",".").replace(" ","")) - except: - self.log_warning(f"Wrong number format {line[line_amount_key]}") - output.append(f"{invoice.number}: dimensions amount format to be updated") - else: - try: - amount = Decimal(line[line_amount_key]) - except: - self.log_warning(f"Wrong number format {line[line_amount_key]}") - output.append(f"{invoice.number}: dimensions amount format to be updated") - else: + # Retrieving with get reduce the repetition of code + amount = line.get(line_amount_key) + # Checking first the case "not exist" allow us to remove one indent level + # NOTE: This works fine because None is not a valid value in this case. + if not amount: self.log_warning(f"Multiple lines or dimensions precedence and no amount for line") continue + # The try-except part is the same and can be extracted + if isinstance(amount , str): + amount = amount.replace(",",".").replace(" ","") + try: + amount = Decimal(line[line_amount_key]) + except: + self.log_warning(f"Wrong number format {line[line_amount_key]}") + output.append(f"{invoice.number}: dimensions amount format to be updated") invoice_line = InvoiceLine( invoice = invoice, @@ -202,14 +184,15 @@ def run(self, data, commit): # create and add dimensions for key, value in line.items(): if key not in exclude and value is not None: - if key +"_"+ str(value) not in dimensions.keys(): + dimkey = f"{key}_{value}" + if dimkey not in dimensions.keys(): dimension = AccountingDimension( name = key, value = str(value) ) dimension.save() - dimensions[key +"_"+ str(value)] = dimension - invoice_line.accounting_dimensions.add(dimensions[key +"_"+ str(value)]) + dimensions[dimkey] = dimension + invoice_line.accounting_dimensions.add(dimensions[dimkey]) self.log_info(f"Accounting dimensions added to Invoice line {invoice_line.id}") @@ -218,3 +201,40 @@ def run(self, data, commit): output.append(f"{invoice.number}: Sum of invoice lines amount to be checked") return '\n'.join(output) + +class bulk_replace_accounting_dimension(Script): + + class Meta: + name = "Replace accounting dimension" + description = "Replace one accounting dimension by another one for all lines" + commit_default = False + + current = ObjectVar( + label="Current dimension", + description="The accounting dimension to be replaced.", + model=AccountingDimension + ) + + new = ObjectVar( + label="New accounting dimension", + description="The new accounting dimension", + model=AccountingDimension + ) + + def run(self, data, commit): + + username = self.request.user.username + self.log_info(f"Running as user {username}") + + output = [] + + current_dimension = data["current"] + new_dimension = data["new"] + + lines = InvoiceLine.objects.filter(accounting_dimensions=current_dimension) + for line in lines: + line.accounting_dimensions.remove(current_dimension) + line.accounting_dimensions.add(new_dimension) + self.log_info(f"invoice {line.invoice.number} updated") + + return '\n'.join(output) From 4e556fefa3786b34cea41099f37836d48a33288e Mon Sep 17 00:00:00 2001 From: Marc Lebreuil Date: Wed, 21 Aug 2024 20:48:13 +0000 Subject: [PATCH 3/4] fix accounting dim access through dynamic fields --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/netbox_contract/__init__.py | 2 +- src/netbox_contract/api/serializers.py | 2 +- src/netbox_contract/forms.py | 8 ++++++-- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5731dcc..5efd4dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ## Version 2 +### Version 2.2.3 + +* Fix accounting dimensions access through Dynamic Object Fields + ### Version 2.2.2 * [154](https://github.com/mlebreuil/netbox-contract/issues/154) Fix edit and delete bulk operations on dimensions and invoice lines. diff --git a/pyproject.toml b/pyproject.toml index 3768bdf..abc2099 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "netbox-contract" -version = "2.2.2" +version = "2.2.3" authors = [ { name="Marc Lebreuil", email="marc@famillelebreuil.net" }, ] diff --git a/src/netbox_contract/__init__.py b/src/netbox_contract/__init__.py index 2e58860..c3228bf 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.2' + version = '2.2.3' 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 9511ac0..7d419ce 100644 --- a/src/netbox_contract/api/serializers.py +++ b/src/netbox_contract/api/serializers.py @@ -284,4 +284,4 @@ class Meta: 'created', 'last_updated', ) - brief_fields = ('name', 'value', 'url', 'display') + brief_fields = ('id', 'name', 'value', 'url', 'display') diff --git a/src/netbox_contract/forms.py b/src/netbox_contract/forms.py index e7300fc..2de9ff0 100644 --- a/src/netbox_contract/forms.py +++ b/src/netbox_contract/forms.py @@ -16,6 +16,7 @@ CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, + CSVModelMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, @@ -459,7 +460,7 @@ class Meta: class InvoiceLineForm(NetBoxModelForm): invoice = DynamicModelChoiceField(queryset=Invoice.objects.all()) - accounting_dimensions = forms.ModelMultipleChoiceField( + accounting_dimensions = DynamicModelMultipleChoiceField( queryset=AccountingDimension.objects.all(), required=False ) @@ -490,6 +491,9 @@ class Meta: class InvoiceLineFilterSetForm(NetBoxModelFilterSetForm): model = InvoiceLine invoice = DynamicModelChoiceField(queryset=Invoice.objects.all(), required=False) + accounting_dimensions = DynamicModelChoiceField( + queryset=AccountingDimension.objects.all(), required=False + ) class InvoiceLineImportForm(NetBoxModelImportForm): @@ -498,7 +502,7 @@ class InvoiceLineImportForm(NetBoxModelImportForm): to_field_name='number', help_text='Invoice number', ) - accounting_dimensions = CSVModelChoiceField( + accounting_dimensions = CSVModelMultipleChoiceField( queryset=AccountingDimension.objects.all(), to_field_name='id', help_text='accounting dimension id', From f7cfd3509228e1f9c0ec7fbb8104ed96b016f7a4 Mon Sep 17 00:00:00 2001 From: Marc Lebreuil Date: Thu, 22 Aug 2024 15:12:32 +0200 Subject: [PATCH 4/4] fix invoice creation --- CHANGELOG.md | 4 +++- src/netbox_contract/forms.py | 4 ++++ src/netbox_contract/views.py | 2 +- contract_migration.py => utils/contract_migration.py | 0 4 files changed, 8 insertions(+), 2 deletions(-) rename contract_migration.py => utils/contract_migration.py (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5efd4dd..9804d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ ### Version 2.2.3 -* Fix accounting dimensions access through Dynamic Object Fields +* Fix accounting dimensions access through Dynamic Object Fields +* Fix invoice creation from contract. +* Add scripts to convert accounting dimensions in the json fields of contract and invoices to invoice template, invoicelines and dimensions objects. ### Version 2.2.2 diff --git a/src/netbox_contract/forms.py b/src/netbox_contract/forms.py index 2de9ff0..eea70e8 100644 --- a/src/netbox_contract/forms.py +++ b/src/netbox_contract/forms.py @@ -241,6 +241,10 @@ class ContractBulkEditForm(NetBoxModelBulkEditForm): class InvoiceForm(NetBoxModelForm): + number = forms.CharField( + max_length=100, + help_text='Invoice template name will be overriden to _invoice_template_contract name', + ) contracts = DynamicModelMultipleChoiceField( queryset=Contract.objects.all(), required=False ) diff --git a/src/netbox_contract/views.py b/src/netbox_contract/views.py index caef4f8..8638f0d 100644 --- a/src/netbox_contract/views.py +++ b/src/netbox_contract/views.py @@ -278,7 +278,7 @@ def get(self, request, *args, **kwargs): contract = Contract.objects.get(pk=initial_data['contracts']) try: - last_invoice = contract.invoices.filter(template=False).latest( + last_invoice = contract.invoices.exclude(template=True).latest( 'period_end' ) new_period_start = last_invoice.period_end + timedelta(days=1) diff --git a/contract_migration.py b/utils/contract_migration.py similarity index 100% rename from contract_migration.py rename to utils/contract_migration.py