diff --git a/CHANGELOG.txt b/CHANGELOG.txt index d42e14feef..15d3bc6018 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -5,7 +5,7 @@ Users side : ------------ - # The version of Django has been upgraded to "4.1". + # The version of Django has been upgraded to "4.2". # A visitor mode has been added; from any list-view, you can visit all the related detail-views the one after the other. (button "Enter the exploration mode" in list-views). # The link in the list-views selectors are now opened in other tabs automatically. @@ -24,8 +24,9 @@ * Activities : - A color field has been added to Status. * Billing : + - The Product/Service lines can be reordered by drag'n drop. + - The mass-import can now import entities with their totals (a Product line is created). - A color field has been added to Statuses. - - Billing lines can be reordered if the document have the right permissions. * Opportunities : - A color field has been added to SalesPhase. * Tickets : diff --git a/creme/activities/forms/mass_import.py b/creme/activities/forms/mass_import.py index 45f1442e9f..199b342ef3 100644 --- a/creme/activities/forms/mass_import.py +++ b/creme/activities/forms/mass_import.py @@ -36,6 +36,7 @@ ImportForm4CremeEntity, ) from creme.creme_core.models import Relation, RelationType +from creme.creme_core.utils import as_int # from creme.creme_core.utils.dates import make_aware_dt from creme.persons.models import Civility @@ -56,12 +57,11 @@ MAX_RELATIONSHIPS = 5 -# TODO: in creme_core ? -def as_int(value, default=0): - try: - return int(value) - except (ValueError, TypeError): - return default +# def as_int(value, default=0): +# try: +# return int(value) +# except (ValueError, TypeError): +# return default class RelatedExtractor: diff --git a/creme/billing/forms/mass_import.py b/creme/billing/forms/mass_import.py index fcb587f871..484e606940 100644 --- a/creme/billing/forms/mass_import.py +++ b/creme/billing/forms/mass_import.py @@ -1,6 +1,6 @@ ################################################################################ # Creme is a free/open-source Customer Relationship Management software -# Copyright (C) 2013-2022 Hybird +# Copyright (C) 2013-2023 Hybird # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -16,23 +16,39 @@ # along with this program. If not, see . ################################################################################ +from __future__ import annotations + +from decimal import Decimal +from functools import partial + +from django import forms from django.conf import settings -from django.forms.fields import BooleanField +from django.core.exceptions import ValidationError +from django.utils.formats import number_format +from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from django.utils.translation import pgettext_lazy from creme import persons from creme.creme_core.forms.mass_import import ( + BaseExtractorWidget, EntityExtractorField, ImportForm4CremeEntity, ) -from creme.creme_core.utils import update_model_instance +from creme.creme_core.models import Vat +from creme.creme_core.utils import as_int, update_model_instance +from .. import get_product_line_model from ..utils import copy_or_create_address Contact = persons.get_contact_model() Organisation = persons.get_organisation_model() +MODE_NO_TOTAL = 1 +MODE_COMPUTE_TOTAL_VAT = 2 +MODE_COMPUTE_TOTAL_NO_VAT = 3 +MODE_COMPUTE_VAT = 4 + def _copy_or_update_address(source, dest, attr_name, addr_name): change = True @@ -51,8 +67,301 @@ def _copy_or_update_address(source, dest, attr_name, addr_name): return change +class _TotalsExtractor: + # TODO: what if ProductLine is not registered? + line_model = get_product_line_model() + + def __init__(self, create_vat=False): + self.create_vat = create_vat + self._vat_validator = Vat._meta.get_field('value').formfield(localize=True).clean + self._amount_validator = self.line_model._meta.get_field( + 'unit_price' + ).formfield(localize=True).clean + + def _extract_total_n_vat(self, line) -> tuple[Decimal, Decimal]: + """Return total without VAT and VAT. + @raise ValidationError. + """ + raise NotImplementedError + + def _clean_total_without_vat(self, value): + try: + return self._amount_validator(value) + except ValidationError as e: + raise ValidationError( + gettext('The total without VAT is invalid: {}').format(e.message) + ) + + def _clean_total_with_vat(self, value): + try: + return self._amount_validator(value) + except ValidationError as e: + raise ValidationError( + gettext('The total with VAT is invalid: {}').format(e.message) + ) + + def _clean_vat(self, value): + try: + cleaned_value = self._vat_validator(value) + except ValidationError as e: + raise ValidationError( + gettext('The VAT value is invalid: {}').format(e.message) + ) + + return self._get_or_create_vat(cleaned_value) + + def _get_or_create_vat(self, value): + try: + vat = Vat.objects.get(value=value) + except Vat.DoesNotExist: + if not self.create_vat: + raise ValidationError( + gettext( + 'The VAT with value «{}» does not exist and cannot be created' + ).format(number_format(value)), + ) + + vat = Vat.objects.create(value=value) + + return vat + + def extract_value(self, line, user): + extracted = None + error_messages = [] + + try: + total_no_vat, vat = self._extract_total_n_vat(line) + except ValidationError as e: + error_messages.append(e.message) + else: + line_model = self.line_model + extracted = line_model( + user=user, + on_the_fly_item=gettext('N/A (import)'), + quantity=Decimal('1'), + discount=Decimal('0'), + discount_unit=line_model.Discount.PERCENT, + unit_price=total_no_vat, + vat_value=vat, + # unit=..., + # comment=..., + ) + # extracted.full_clean() TODO? + + return extracted, error_messages + + +class TotalWithVatExtractor(_TotalsExtractor): + def __init__(self, *, total_no_vat_index, vat_index, create_vat=False): + super().__init__(create_vat=create_vat) + self._total_no_vat_index = total_no_vat_index - 1 + self._vat_index = vat_index - 1 + + def _extract_total_n_vat(self, line): + vat = self._clean_vat(line[self._vat_index]) + total_no_vat = self._clean_total_without_vat(line[self._total_no_vat_index]) + + return total_no_vat, vat + + +class TotalWithoutVatExtractor(_TotalsExtractor): + def __init__(self, *, total_vat_index, vat_index, create_vat=False): + super().__init__(create_vat=create_vat) + self._total_vat_index = total_vat_index - 1 + self._vat_index = vat_index - 1 + + def _extract_total_n_vat(self, line): + vat = self._clean_vat(line[self._vat_index]) + total_vat = self._clean_total_with_vat(line[self._total_vat_index]) + + return (total_vat / (Decimal(1) + vat.value / Decimal(100))), vat + + +class VatExtractor(_TotalsExtractor): + def __init__(self, *, total_no_vat_index, total_vat_index, create_vat=False): + super().__init__(create_vat=create_vat) + self._total_no_vat_index = total_no_vat_index - 1 + self._total_vat_index = total_vat_index - 1 + + def _extract_total_n_vat(self, line): + total_no_vat = self._clean_total_without_vat(line[self._total_no_vat_index]) + total_vat = self._clean_total_with_vat(line[self._total_vat_index]) + vat_value = (total_vat / total_no_vat - Decimal(1)) * Decimal(100) + + return total_no_vat, self._get_or_create_vat(vat_value) + + +class TotalsExtractorWidget(BaseExtractorWidget): + template_name = 'billing/forms/widgets/totals-extractor.html' + + def get_context(self, name, value, attrs): + value = value or {} + context = super().get_context(name=name, value=value, attrs=attrs) + + widget_cxt = context['widget'] + widget_cxt['MODE_NO_TOTAL'] = MODE_NO_TOTAL + widget_cxt['MODE_COMPUTE_TOTAL_VAT'] = MODE_COMPUTE_TOTAL_VAT + widget_cxt['MODE_COMPUTE_TOTAL_NO_VAT'] = MODE_COMPUTE_TOTAL_NO_VAT + widget_cxt['MODE_COMPUTE_VAT'] = MODE_COMPUTE_VAT + widget_cxt['mode'] = value.get('mode', MODE_NO_TOTAL) + + id_attr = widget_cxt['attrs']['id'] + + def column_select_context(name_fmt, selected_key): + return self.column_select.get_context( + name=name_fmt.format(name), + value=value.get(selected_key), + attrs={ + 'id': name_fmt.format(id_attr), + 'class': 'csv_col_select', + }, + )['widget'] + + widget_cxt['totalnovat_column_select'] = column_select_context( + name_fmt='{}_total_no_vat_colselect', + selected_key='total_no_vat_column_index', + ) + widget_cxt['totalvat_column_select'] = column_select_context( + name_fmt='{}_total_vat_colselect', + selected_key='total_vat_column_index', + ) + widget_cxt['vat_column_select'] = column_select_context( + name_fmt='{}_vat_colselect', + selected_key='vat_column_index', + ) + + return context + + def value_from_datadict(self, data, files, name): + get = data.get + + return { + 'mode': as_int(get(f'{name}_mode'), 1), + + 'total_no_vat_column_index': as_int(get(f'{name}_total_no_vat_colselect')), + 'total_vat_column_index': as_int(get(f'{name}_total_vat_colselect')), + 'vat_column_index': as_int(get(f'{name}_vat_colselect')), + } + + +class TotalsExtractorField(forms.Field): + default_error_messages = { + 'column_required': _('You have to select a column for «%(field)s».'), + } + index_verbose_names = { + 'total_no_vat_column_index': _('Total without VAT'), + 'total_vat_column_index': _('Total with VAT'), + 'vat_column_index': _('VAT'), + } + extractors = { + MODE_NO_TOTAL: (None, {}), # TODO: EmptyExtractor?? + MODE_COMPUTE_TOTAL_VAT: ( + TotalWithVatExtractor, + { + 'total_no_vat_index': 'total_no_vat_column_index', + 'vat_index': 'vat_column_index', + }, + ), + MODE_COMPUTE_TOTAL_NO_VAT: ( + TotalWithoutVatExtractor, + { + 'total_vat_index': 'total_vat_column_index', + 'vat_index': 'vat_column_index', + }, + ), + MODE_COMPUTE_VAT: ( + VatExtractor, + { + 'total_vat_index': 'total_vat_column_index', + 'total_no_vat_index': 'total_no_vat_column_index', + }, + ) + } + + def __init__(self, *, choices, **kwargs): + super().__init__(widget=TotalsExtractorWidget, **kwargs) + self._allowed_indexes = {c[0] for c in choices} + + self.user = None + self.widget.choices = choices + + @property + def can_create_vat(self): + user = self._user + return user is not None and user.has_perm_to_admin('creme_core') + + @property + def user(self): + return self._user + + @user.setter + def user(self, user): + self._user = user + # NB: probably not great to override help_text, but this field should + # not be used elsewhere... + self.help_text = ( + '' + if self.can_create_vat else + _('Beware: you are not allowed to create new VAT values') + ) + + def _clean_index(self, value, key): + try: + index = int(value[key]) + except KeyError as e: + raise ValidationError(f'Index "{key}" is required') from e + except ValueError as e: + raise ValidationError(f'Index "{key}" should be an integer') from e + + if index not in self._allowed_indexes: + raise ValidationError('Invalid index') + + if not index: + raise ValidationError( + self.error_messages['column_required'], + code='column_required', + params={'field': self.index_verbose_names.get(key, '??')}, + ) + + return index + + def _clean_mode(self, value): + try: + mode = int(value['mode']) + except KeyError as e: + if self.required: + raise ValidationError('Mode is required') from e + mode = MODE_NO_TOTAL + except ValueError as e: + raise ValidationError('Invalid value for mode') from e + + return mode + + def clean(self, value): + mode = self._clean_mode(value) + + try: + extractor_cls, args_descriptors = self.extractors[mode] + except KeyError: + raise ValidationError('Invalid mode') + + if extractor_cls is None: + return None + + clean_index = partial(self._clean_index, value) + + return extractor_cls( + create_vat=self.can_create_vat, + **{ + arg_name: clean_index(col_name) + for arg_name, col_name in args_descriptors.items() + }, + ) + + def get_import_form_builder(header_dict, choices): - class InvoiceMassImportForm(ImportForm4CremeEntity): + # class InvoiceMassImportForm(ImportForm4CremeEntity): + class BillingMassImportForm(ImportForm4CremeEntity): source = EntityExtractorField( models_info=[(Organisation, 'name')], choices=choices, @@ -66,15 +375,35 @@ class InvoiceMassImportForm(ImportForm4CremeEntity): choices=choices, label=pgettext_lazy('billing', 'Target'), ) - override_billing_addr = BooleanField( + override_billing_addr = forms.BooleanField( label=_('Update the billing address'), required=False, help_text=_('In update mode, update the billing address from the target.'), ) - override_shipping_addr = BooleanField( + override_shipping_addr = forms.BooleanField( label=_('Update the shipping address'), required=False, help_text=_('In update mode, update the shipping address from the target.'), ) + totals = TotalsExtractorField(choices=choices, label=_('Totals & VAT')) + + class Meta: + exclude = ('discount',) # NB: if uncommented, should be used in totals computing + + blocks = ImportForm4CremeEntity.blocks.new( + { + 'id': 'organisations_and_addresses', + 'label': _('Organisations'), + 'fields': [ + 'source', 'target', 'override_billing_addr', 'override_shipping_addr', + ], + }, + { + 'id': 'totals', + 'label': _('Totals & VAT'), + 'fields': ['totals'], + }, + ) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -88,6 +417,16 @@ def __init__(self, *args, **kwargs): models=model._meta.verbose_name_plural, ) + def clean_totals(self): + extractor = self.cleaned_data['totals'] + + if extractor is not None and self.cleaned_data.get('key_fields'): + raise ValidationError( + gettext('You cannot compute totals in update mode.') + ) + + return extractor + def _pre_instance_save(self, instance, line): cdata = self.cleaned_data append_error = self.append_error @@ -102,9 +441,20 @@ def _pre_instance_save(self, instance, line): def _post_instance_creation(self, instance, line, updated): super()._post_instance_creation(instance, line, updated) + cdata = self.cleaned_data + + line_extractor = cdata['totals'] + if line_extractor is not None: + line, errors = line_extractor.extract_value(line, self.user) + + if line: + line.related_document = instance + line.save() + + for error in errors: + self.append_error(error) if updated: - cdata = self.cleaned_data target = instance.target b_change = s_change = False @@ -121,4 +471,5 @@ def _post_instance_creation(self, instance, line, updated): if b_change or s_change: instance.save() - return InvoiceMassImportForm + # return InvoiceMassImportForm + return BillingMassImportForm diff --git a/creme/billing/locale/fr/LC_MESSAGES/django.po b/creme/billing/locale/fr/LC_MESSAGES/django.po index 19c6a23028..a124a6cde2 100644 --- a/creme/billing/locale/fr/LC_MESSAGES/django.po +++ b/creme/billing/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Creme Billing 2.5\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-03-07 14:25+0100\n" +"POT-Creation-Date: 2023-04-26 15:24+0200\n" "Last-Translator: Hybird \n" "Language: fr\n" "MIME-Version: 1.0\n" @@ -389,6 +389,37 @@ msgstr "" "Vous n'avez pas la permission d'à ajouter cet élément au catalogue car il ne " "s'agit pas d'une ligne à la volée" +msgid "The total without VAT is invalid: {}" +msgstr "Le total HT est invalide : {}" + +msgid "The total with VAT is invalid: {}" +msgstr "Le total TTC est invalide : {}" + +msgid "The VAT value is invalid: {}" +msgstr "La valeur de TVA est invalide : {}" + +msgid "The VAT with value «{}» does not exist and cannot be created" +msgstr "La TVA avec la valeur «{}» n'existe pas et ne peut pas être créée" + +msgid "N/A (import)" +msgstr "NC (import)" + +#, python-format +msgid "You have to select a column for «%(field)s»." +msgstr "Vous devez sélectionner une colonne pour «%(field)s»." + +msgid "Total without VAT" +msgstr "Total HT" + +msgid "Total with VAT" +msgstr "Total TTC" + +msgid "VAT" +msgstr "TVA" + +msgid "Beware: you are not allowed to create new VAT values" +msgstr "Vous n'avez pas la permission de créer de nouvelles valeurs de TVA" + msgid "Update the billing address" msgstr "Mettre à jour l'adresse de facturation" @@ -403,6 +434,9 @@ msgid "In update mode, update the shipping address from the target." msgstr "" "En mode mise-à-jour, mettre à jour l'adresse de livraison depuis la cible." +msgid "Totals & VAT" +msgstr "Totaux & TVA" + #, python-brace-format msgid "" "If you chose an organisation managed by {software} as source organisation, a " @@ -411,6 +445,9 @@ msgstr "" "Si vous choisissez une société gérée par {software} en tant qu'émettrice, un " "nombre sera automatiquement généré pour les «{models}» créé(e)s." +msgid "You cannot compute totals in update mode." +msgstr "Vous ne pouvez pas calculer les totaux en mode mise-à-jour." + msgid "Billing address" msgstr "Adresse de facturation" @@ -472,12 +509,6 @@ msgstr "Devise" msgid "Comment" msgstr "Remarques" -msgid "Total with VAT" -msgstr "Total avec TVA" - -msgid "Total without VAT" -msgstr "Total HT" - msgid "Additional Information" msgstr "Informations supplémentaires" @@ -556,9 +587,6 @@ msgstr "Unité" msgid "Discount Unit" msgstr "Unité de remise" -msgid "VAT" -msgstr "TVA" - msgid "Create a line" msgstr "Créer une ligne" @@ -1186,6 +1214,39 @@ msgstr "Vous n'avez pas la permission de relier cette fiche" msgid "You are not allowed to change this entity" msgstr "Vous n'avez pas la permission de modifier cette fiche" +msgid "The totals are just set to 0" +msgstr "Les totaux sont jste mis à 0" + +msgid "" +"Total with VAT is computed from total without VAT & VAT (one product line is " +"created to get the right totals)" +msgstr "" +"Le total TTC est calculé depuis le total HT & la TVA (une line produit est " +"créée pour obtenir les bons totaux)" + +msgid "" +"Total without VAT is computed from total with VAT & VAT (one product line is " +"created to get the right totals)" +msgstr "" +"Le total HT est calculé depuis le total TTC & la TVA (une line produit est " +"créée pour obtenir les bons totaux)" + +msgid "" +"VAT is computed from totals (one product line is created to get the right " +"totals)" +msgstr "" +"La TVA est calculée depuis les totaux (une line produit est " +"créée pour obtenir les bons totaux)" + +msgid "Total without VAT: " +msgstr "Total HT : " + +msgid "Total with VAT: " +msgstr "Total TTC : " + +msgid "VAT: " +msgstr "TVA : " + #, python-format msgid "Errors on the line «%(item)s»:" msgstr "Erreurs sur la ligne «%(item)s» :" diff --git a/creme/billing/static/chantilly/billing/css/billing.css b/creme/billing/static/chantilly/billing/css/billing.css index 76797d858a..cbe7c9f9fb 100644 --- a/creme/billing/static/chantilly/billing/css/billing.css +++ b/creme/billing/static/chantilly/billing/css/billing.css @@ -460,4 +460,25 @@ div.billing-exporter-select > div > label { font-style: italic; } +/* Mass-import forms */ + +.billing-mass_import-totals { + display: flex; +} + +.billing-mass_import-totals .billing-mass_import-totals-columns { + display: flex; + flex-direction: column; + justify-content: space-between; + + margin-left: 20px; + padding-left: 20px; + + border-left: solid 1px #d9d9d9; +} + +.billing-mass_import-totals .billing-mass_import-totals-columns li.is-disabled { + color: #c4d1d7; +} + /* Forms - end */ diff --git a/creme/billing/static/icecream/billing/css/billing.css b/creme/billing/static/icecream/billing/css/billing.css index 3e8171e23f..6e0fb5595c 100644 --- a/creme/billing/static/icecream/billing/css/billing.css +++ b/creme/billing/static/icecream/billing/css/billing.css @@ -456,4 +456,25 @@ div.billing-exporter-select > div > label { font-style: italic; } +/* Mass-import forms */ + +.billing-mass_import-totals { + display: flex; +} + +.billing-mass_import-totals .billing-mass_import-totals-columns { + display: flex; + flex-direction: column; + justify-content: space-between; + + margin-left: 20px; + padding-left: 20px; + + border-left: solid 1px #d9d9d9; +} + +.billing-mass_import-totals .billing-mass_import-totals-columns li.is-disabled { + color: #b1b1b1; +} + /* Forms - end */ \ No newline at end of file diff --git a/creme/billing/templates/billing/forms/widgets/totals-extractor.html b/creme/billing/templates/billing/forms/widgets/totals-extractor.html new file mode 100644 index 0000000000..ef384bea9b --- /dev/null +++ b/creme/billing/templates/billing/forms/widgets/totals-extractor.html @@ -0,0 +1,79 @@ +{% load i18n %} +{% with name=widget.name id=widget.attrs.id mode=widget.mode %} +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
    +
  • + {% with widget=widget.totalnovat_column_select %} + + {% endwith %} +
  • +
  • + {% with widget=widget.totalvat_column_select %} + + {% endwith %} +
  • +
  • + {% with widget=widget.vat_column_select %} + + {% endwith %} +
  • +
+ +
+{% endwith %} \ No newline at end of file diff --git a/creme/billing/tests/base.py b/creme/billing/tests/base.py index 95fb3bf09d..e3f20d2004 100644 --- a/creme/billing/tests/base.py +++ b/creme/billing/tests/base.py @@ -5,10 +5,11 @@ from django.test.utils import override_settings from django.urls import reverse +from django.utils.formats import number_format from django.utils.translation import gettext as _ from creme import billing -from creme.creme_core.models import Currency +from creme.creme_core.models import Currency, Vat from creme.creme_core.tests.base import CremeTestCase from creme.creme_core.tests.views import base from creme.persons import ( @@ -312,7 +313,8 @@ class _BillingTestCase(_BillingTestCaseMixin, base.MassImportBaseTestCaseMixin, CremeTestCase): @override_settings(SOFTWARE_LABEL='My CRM') - def _aux_test_csv_import(self, model, status_model, update=False, number_help_text=True): + def _aux_test_csv_import_no_total(self, model, status_model, + update=False, number_help_text=True): count = model.objects.count() create_orga = partial(Organisation.objects.create, user=self.user) create_contact = partial(Contact.objects.create, user=self.user) @@ -446,6 +448,8 @@ def _aux_test_csv_import(self, model, status_model, update=False, number_help_te 'description_colselect': 0, 'buyers_order_number_colselect': 0, # Invoice only... + 'totals_mode': '1', # No totals + # 'property_types', # 'fixed_relations', # 'dyn_relations', @@ -512,6 +516,10 @@ def _aux_test_csv_import(self, model, status_model, update=False, number_help_te self.assertIsNone(billing_doc.payment_terms) # self.assertIsNone(billing_doc.payment_type) #only in invoice... TODO lambda ?? + self.assertEqual(Decimal('0.0'), billing_doc.total_vat) + self.assertEqual(Decimal('0.0'), billing_doc.total_no_vat) + self.assertFalse([*billing_doc.iter_all_lines()]) + # Billing_doc1 billing_doc1 = billing_docs[0] imp_source1 = billing_doc1.source @@ -553,6 +561,121 @@ def _aux_test_csv_import(self, model, status_model, update=False, number_help_te target4 = self.get_object_or_fail(Contact, last_name=target4_last_name) self.assertEqual(imp_target4.get_real_entity(), target4) + def _aux_test_csv_import_total_no_vat_n_vat(self, model, status_model): + count = model.objects.count() + + create_orga = partial(Organisation.objects.create, user=self.user) + src = create_orga(name='Nerv') + tgt = create_orga(name='Acme') + + vat1 = 15 + vat_obj1 = Vat.objects.get_or_create(value=vat1)[0] + vat2 = '12.5' + self.assertFalse(Vat.objects.filter(value=vat2).exists()) + vat_count = Vat.objects.count() + + total_no_vat1 = 100 + total_no_vat2 = '200.5' + + lines = [ + ('Bill #1', src.name, tgt.name, number_format(total_no_vat1), number_format(vat1)), + ('Bill #2', src.name, tgt.name, number_format(total_no_vat2), number_format(vat2)), + ('Bill #3', src.name, tgt.name, '300', 'nan'), + ] + doc = self._build_csv_doc(lines) + response = self.client.post( + self._build_import_url(model), + follow=True, + data={ + 'step': 1, + 'document': doc.id, + # has_header + + 'user': self.user.id, + # 'key_fields': ['name'] if update else [], + + 'name_colselect': 1, + 'number_colselect': 0, + + 'issuing_date_colselect': 0, + 'expiration_date_colselect': 0, + + 'status_colselect': 0, + 'status_defval': status_model.objects.all()[0].pk, + + 'discount_colselect': 0, + 'discount_defval': '0', + + 'currency_colselect': 0, + 'currency_defval': Currency.objects.all()[0].pk, + + 'acceptation_date_colselect': 0, + + 'comment_colselect': 0, + 'additional_info_colselect': 0, + 'payment_terms_colselect': 0, + 'payment_type_colselect': 0, + + 'description_colselect': 0, + 'buyers_order_number_colselect': 0, # Invoice only... + + 'source_persons_organisation_colselect': 2, + 'target_persons_organisation_colselect': 3, + 'target_persons_contact_colselect': 0, + + 'totals_mode': '2', # Compute total with VAT + 'totals_total_no_vat_colselect': 4, + 'totals_vat_colselect': 5, + + # 'property_types', + # 'fixed_relations', + # 'dyn_relations', + }, + ) + self.assertNoFormError(response) + + job = self._execute_job(response) + self.assertEqual(count + len(lines), model.objects.count()) + + billing_doc1 = self.get_object_or_fail(model, name=lines[0][0]) + self.assertEqual(Decimal('0.0'), billing_doc1.discount) + self.assertEqual(Decimal(total_no_vat1), billing_doc1.total_no_vat) + self.assertEqual(Decimal('115.00'), billing_doc1.total_vat) + + line1 = self.get_alone_element(billing_doc1.iter_all_lines()) + self.assertIsInstance(line1, ProductLine) + self.assertEqual(_('N/A (import)'), line1.on_the_fly_item) + self.assertFalse(line1.comment) + self.assertEqual(1, line1.quantity) + self.assertEqual(total_no_vat1, line1.unit_price) + self.assertFalse(line1.unit) + self.assertEqual(0, line1.discount) + self.assertEqual(ProductLine.Discount.PERCENT, line1.discount_unit) + self.assertEqual(vat_obj1, line1.vat_value) + + billing_doc2 = self.get_object_or_fail(model, name=lines[1][0]) + self.assertEqual(Decimal(total_no_vat2), billing_doc2.total_no_vat) + self.assertEqual(Decimal('225.56'), billing_doc2.total_vat) + + self.assertEqual(vat_count + 1, Vat.objects.count()) + line2 = self.get_alone_element(billing_doc2.iter_all_lines()) + self.assertEqual(Decimal(total_no_vat2), line2.unit_price) + self.assertEqual(Decimal(vat2), line2.vat_value.value) + + billing_doc3 = self.get_object_or_fail(model, name=lines[2][0]) + self.assertEqual(Decimal('0'), billing_doc3.total_no_vat) + self.assertEqual(Decimal('0'), billing_doc3.total_vat) + self.assertFalse([*billing_doc3.iter_all_lines()]) + + results = self._get_job_results(job) + self.assertEqual(len(lines), len(results)) + + jr_error3 = self.get_alone_element(r for r in results if r.entity_id == billing_doc3.id) + self.assertListEqual( + [_('The VAT value is invalid: {}').format(_('Enter a number.'))], + jr_error3.messages, + ) + def _aux_test_csv_import_update(self, model, status_model, target_billing_address=True, override_billing_addr=False, diff --git a/creme/billing/tests/test_forms.py b/creme/billing/tests/test_forms.py new file mode 100644 index 0000000000..76aa1eba14 --- /dev/null +++ b/creme/billing/tests/test_forms.py @@ -0,0 +1,603 @@ +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.utils.formats import number_format +from django.utils.translation import gettext as _ +from parameterized import parameterized + +from creme.billing.forms.mass_import import ( + TotalsExtractorField, + TotalWithoutVatExtractor, + TotalWithVatExtractor, + VatExtractor, +) +from creme.billing.tests.base import ProductLine +from creme.creme_core.models import UserRole, Vat +from creme.creme_core.tests.base import CremeTestCase + +MODE_NO_TOTAL = '1' +MODE_COMPUTE_TOTAL_VAT = '2' +MODE_COMPUTE_TOTAL_NO_VAT = '3' +MODE_COMPUTE_VAT = '4' + + +class TotalsExtractorFieldTestCase(CremeTestCase): + choices = [(0, 'No column'), (1, 'Column #1'), (2, 'Column #2')] + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.user = cls.create_user() + + def test_extractor_total_with_vat(self): + extractor = TotalWithVatExtractor(total_no_vat_index=1, vat_index=2) + self.assertEqual(0, extractor._total_no_vat_index) + self.assertEqual(1, extractor._vat_index) + + vat = Vat.objects.get_or_create(value=Decimal('10.0'))[0] + + with self.assertNoException(): + pline, errors = extractor.extract_value( + user=self.user, + line=[number_format('100.0'), number_format(vat.value)], + ) + + self.assertEqual([], errors) + + self.assertIsInstance(pline, ProductLine) + self.assertIsNone(pline.pk) + self.assertEqual(self.user, pline.user) + self.assertEqual(_('N/A (import)'), pline.on_the_fly_item) + self.assertEqual(Decimal('1'), pline.quantity) + self.assertEqual(Decimal('0'), pline.discount) + self.assertEqual(ProductLine.Discount.PERCENT, pline.discount_unit) + self.assertEqual(Decimal('100.0'), pline.unit_price) + self.assertEqual(vat, pline.vat_value) + + def test_extractor_total_with_vat_errors(self): + extractor = TotalWithVatExtractor( + total_no_vat_index=1, vat_index=2, create_vat=True, + ) + + # Empty VAT --- + with self.assertNoException(): + pline1, errors1 = extractor.extract_value( + user=self.user, + line=[number_format('100.0'), ''], + ) + self.assertIsNone(pline1) + self.assertListEqual( + [_('The VAT value is invalid: {}').format(_('This field is required.'))], + errors1, + ) + + # Invalid VAT --- + with self.assertNoException(): + pline2, errors2 = extractor.extract_value( + user=self.user, + line=[number_format('100.0'), 'Nan'], + ) + self.assertIsNone(pline2) + self.assertListEqual( + [_('The VAT value is invalid: {}').format(_('Enter a number.'))], + errors2, + ) + + # Empty Total --- + with self.assertNoException(): + pline3, errors3 = extractor.extract_value( + user=self.user, + line=['', number_format('10.0')], + ) + self.assertIsNone(pline3) + self.assertListEqual( + [_('The total without VAT is invalid: {}').format(_('This field is required.'))], + errors3, + ) + + # Invalid Total --- + with self.assertNoException(): + pline4, errors4 = extractor.extract_value( + user=self.user, + line=['Nan', number_format('15.0')], + ) + self.assertIsNone(pline4) + self.assertListEqual( + [_('The total without VAT is invalid: {}').format(_('Enter a number.'))], + errors4, + ) + + def test_extractor_total_without_vat(self): + extractor = TotalWithoutVatExtractor(total_vat_index=1, vat_index=2) + self.assertEqual(0, extractor._total_vat_index) + self.assertEqual(1, extractor._vat_index) + + vat = Vat.objects.get_or_create(value=Decimal('10.5'))[0] + + with self.assertNoException(): + pline, errors = extractor.extract_value( + user=self.user, + line=[number_format('221.0'), number_format(vat.value)], + ) + + self.assertEqual([], errors) + + self.assertIsInstance(pline, ProductLine) + self.assertIsNone(pline.pk) + self.assertEqual(self.user, pline.user) + self.assertEqual(_('N/A (import)'), pline.on_the_fly_item) + self.assertEqual(Decimal('1'), pline.quantity) + self.assertEqual(Decimal('0'), pline.discount) + self.assertEqual(ProductLine.Discount.PERCENT, pline.discount_unit) + self.assertEqual(Decimal('200.0'), pline.unit_price) + self.assertEqual(vat, pline.vat_value) + + def test_extractor_total_without_vat_errors(self): + extractor = TotalWithoutVatExtractor( + total_vat_index=1, vat_index=2, create_vat=True, + ) + + # Empty VAT --- + with self.assertNoException(): + pline1, errors1 = extractor.extract_value( + user=self.user, + line=[number_format('100.0'), ''], + ) + self.assertIsNone(pline1) + self.assertListEqual( + [_('The VAT value is invalid: {}').format(_('This field is required.'))], + errors1, + ) + + # Invalid VAT --- + with self.assertNoException(): + pline2, errors2 = extractor.extract_value( + user=self.user, + line=[number_format('100.0'), 'Nan'], + ) + self.assertIsNone(pline2) + self.assertListEqual( + [_('The VAT value is invalid: {}').format(_('Enter a number.'))], + errors2, + ) + + # Empty Total --- + with self.assertNoException(): + pline3, errors3 = extractor.extract_value( + user=self.user, + line=['', number_format('10.0')], + ) + self.assertIsNone(pline3) + self.assertListEqual( + [_('The total with VAT is invalid: {}').format(_('This field is required.'))], + errors3, + ) + + # Invalid Total --- + with self.assertNoException(): + pline4, errors4 = extractor.extract_value( + user=self.user, + line=['Nan', number_format('15.0')], + ) + self.assertIsNone(pline4) + self.assertListEqual( + [_('The total with VAT is invalid: {}').format(_('Enter a number.'))], + errors4, + ) + + def test_extractor_vat(self): + extractor = VatExtractor(total_no_vat_index=1, total_vat_index=2) + self.assertEqual(0, extractor._total_no_vat_index) + self.assertEqual(1, extractor._total_vat_index) + + vat = Vat.objects.get_or_create(value=Decimal('10'))[0] + + with self.assertNoException(): + pline, errors = extractor.extract_value( + user=self.user, + line=[number_format('300.0'), number_format('330.0')], + ) + + self.assertEqual([], errors) + + self.assertIsInstance(pline, ProductLine) + self.assertIsNone(pline.pk) + self.assertEqual(self.user, pline.user) + self.assertEqual(_('N/A (import)'), pline.on_the_fly_item) + self.assertEqual(Decimal('1'), pline.quantity) + self.assertEqual(Decimal('0'), pline.discount) + self.assertEqual(ProductLine.Discount.PERCENT, pline.discount_unit) + self.assertEqual(Decimal('300.0'), pline.unit_price) + self.assertEqual(vat, pline.vat_value) + + def test_extractor_vat_errors(self): + extractor = VatExtractor(total_no_vat_index=1, total_vat_index=2) + + # Invalid Total without VAT --- + with self.assertNoException(): + pline1, errors1 = extractor.extract_value( + user=self.user, + line=['Nan', number_format('15.0')], + ) + self.assertIsNone(pline1) + self.assertListEqual( + [_('The total without VAT is invalid: {}').format(_('Enter a number.'))], + errors1, + ) + + # Invalid Total with VAT --- + with self.assertNoException(): + pline2, errors2 = extractor.extract_value( + user=self.user, + line=[number_format('15.0'), 'Nan'], + ) + self.assertIsNone(pline2) + self.assertListEqual( + [_('The total with VAT is invalid: {}').format(_('Enter a number.'))], + errors2, + ) + + def test_extractor_extractor_total_with_vat__vat_creation01(self): + extractor = TotalWithVatExtractor( + total_no_vat_index=1, vat_index=2, + create_vat=True, + ) + self.assertTrue(extractor.create_vat) + + vat = '10.0' + self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists()) + + with self.assertNoException(): + pline, errors = extractor.extract_value( + user=self.user, + line=[number_format('100.0'), number_format(vat)], + ) + + self.assertEqual([], errors) + self.assertEqual(Decimal('100.0'), pline.unit_price) + + vat_obj = self.get_object_or_fail(Vat, value=Decimal(vat)) + self.assertEqual(vat_obj, pline.vat_value) + + def test_extractor_extractor_total_with_vat__vat_creation02(self): + "Not allowed." + extractor = TotalWithVatExtractor(total_no_vat_index=1, vat_index=2) + self.assertFalse(extractor.create_vat) + + vat = '10.0' + self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists()) + + with self.assertNoException(): + pline, errors = extractor.extract_value( + user=self.user, + line=[number_format('100.0'), number_format(vat)], + ) + + self.assertIsNone(pline) + self.assertEqual( + [ + _('The VAT with value «{}» does not exist and cannot be created').format( + number_format(vat), + ), + ], + errors, + ) + self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists()) + + def test_extractor_extractor_total_without_vat__vat_creation01(self): + extractor = TotalWithoutVatExtractor( + total_vat_index=1, vat_index=2, + create_vat=True, + ) + self.assertTrue(extractor.create_vat) + + vat = '10.0' + self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists()) + + with self.assertNoException(): + pline, errors = extractor.extract_value( + user=self.user, + line=[number_format('110.0'), number_format(vat)], + ) + + self.assertEqual([], errors) + self.assertEqual(Decimal('100.0'), pline.unit_price) + + vat_obj = self.get_object_or_fail(Vat, value=Decimal(vat)) + self.assertEqual(vat_obj, pline.vat_value) + + def test_extractor_extractor_total_without_vat__vat_creation02(self): + "Not allowed." + extractor = TotalWithoutVatExtractor(total_vat_index=1, vat_index=2) + self.assertFalse(extractor.create_vat) + + vat = '10.0' + self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists()) + + with self.assertNoException(): + pline, errors = extractor.extract_value( + user=self.user, + line=[number_format('110.0'), number_format(vat)], + ) + + self.assertIsNone(pline) + self.assertEqual( + [ + _('The VAT with value «{}» does not exist and cannot be created').format( + number_format(vat), + ), + ], + errors, + ) + self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists()) + + def test_extractor_extractor_vat__vat_creation01(self): + extractor = VatExtractor( + total_no_vat_index=1, total_vat_index=2, + create_vat=True, + ) + self.assertTrue(extractor.create_vat) + + vat = '10.0' + self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists()) + + with self.assertNoException(): + pline, errors = extractor.extract_value( + user=self.user, + line=[number_format('100.0'), number_format('110.0')], + ) + + self.assertEqual([], errors) + self.assertEqual(Decimal('100.0'), pline.unit_price) + + vat_obj = self.get_object_or_fail(Vat, value=Decimal(vat)) + self.assertEqual(vat_obj, pline.vat_value) + + def test_extractor_extractor_vat__vat_creation02(self): + "Not allowed." + extractor = VatExtractor(total_no_vat_index=1, total_vat_index=2) + self.assertFalse(extractor.create_vat) + + vat = '10.0' + self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists()) + + with self.assertNoException(): + pline, errors = extractor.extract_value( + user=self.user, + line=[number_format('100.0'), number_format('110.00')], + ) + + self.assertIsNone(pline) + self.assertEqual( + [ + _('The VAT with value «{}» does not exist and cannot be created').format( + number_format(vat), + ), + ], + errors, + ) + self.assertFalse(Vat.objects.filter(value=Decimal(vat)).exists()) + + def test_field_no_total(self): + field = TotalsExtractorField(choices=self.choices) + self.assertIsNone(field.clean({'mode': MODE_NO_TOTAL})) + + def test_field_total_no_vat_n_vat(self): + field1 = TotalsExtractorField(choices=self.choices) + + with self.assertNoException(): + extractor1 = field1.clean({ + 'mode': MODE_COMPUTE_TOTAL_VAT, + 'total_no_vat_column_index': 1, + 'vat_column_index': 2, + }) + + self.assertIsInstance(extractor1, TotalWithVatExtractor) + self.assertEqual(0, extractor1._total_no_vat_index) + self.assertEqual(1, extractor1._vat_index) + + # Other indexes --- + field2 = TotalsExtractorField(choices=self.choices) + extractor2 = field2.clean({ + 'mode': MODE_COMPUTE_TOTAL_VAT, + 'total_no_vat_column_index': 2, + 'vat_column_index': 1, + }) + self.assertEqual(1, extractor2._total_no_vat_index) + self.assertEqual(0, extractor2._vat_index) + + def test_field_total_vat_n_vat(self): + field1 = TotalsExtractorField(choices=self.choices) + + with self.assertNoException(): + extractor1 = field1.clean({ + 'mode': MODE_COMPUTE_TOTAL_NO_VAT, + 'total_vat_column_index': 1, + 'vat_column_index': 2, + }) + + self.assertIsInstance(extractor1, TotalWithoutVatExtractor) + self.assertEqual(0, extractor1._total_vat_index) + self.assertEqual(1, extractor1._vat_index) + + # Other indexes --- + field2 = TotalsExtractorField(choices=self.choices) + extractor2 = field2.clean({ + 'mode': MODE_COMPUTE_TOTAL_NO_VAT, + 'total_vat_column_index': 2, + 'vat_column_index': 1, + }) + self.assertEqual(1, extractor2._total_vat_index) + self.assertEqual(0, extractor2._vat_index) + + def test_field_totals(self): + field1 = TotalsExtractorField(choices=self.choices) + + with self.assertNoException(): + extractor1 = field1.clean({ + 'mode': MODE_COMPUTE_VAT, + 'total_vat_column_index': 1, + 'total_no_vat_column_index': 2, + }) + + self.assertIsInstance(extractor1, VatExtractor) + self.assertEqual(0, extractor1._total_vat_index) + self.assertEqual(1, extractor1._total_no_vat_index) + + # Other indexes --- + field2 = TotalsExtractorField(choices=self.choices) + extractor2 = field2.clean({ + 'mode': MODE_COMPUTE_VAT, + 'total_vat_column_index': 2, + 'total_no_vat_column_index': 1, + }) + self.assertEqual(1, extractor2._total_vat_index) + self.assertEqual(0, extractor2._total_no_vat_index) + + def test_field_invalid_mode(self): + field = TotalsExtractorField(choices=self.choices) + + with self.assertRaises(ValidationError): + field.clean({ + 'mode': '6', + 'total_vat_column_index': 1, + 'total_no_vat_column_index': 2, + }) + + with self.assertRaises(ValidationError): + field.clean({ + 'mode': 'nan', + 'total_vat_column_index': 1, + 'total_no_vat_column_index': 2, + }) + + with self.assertRaises(ValidationError): + field.clean({ + # 'mode': '2', + 'total_vat_column_index': 1, + 'total_no_vat_column_index': 2, + }) + + def test_field_invalid_index(self): + field = TotalsExtractorField(choices=self.choices) + + # --- + with self.assertRaises(ValidationError) as cm1: + field.clean({ + 'mode': MODE_COMPUTE_VAT, + 'total_vat_column_index': 'nan', + 'total_no_vat_column_index': 2, + }) + self.assertEqual( + 'Index "total_vat_column_index" should be an integer', + cm1.exception.message, + ) + + # --- + with self.assertRaises(ValidationError) as cm2: + field.clean({ + 'mode': MODE_COMPUTE_VAT, + # 'total_vat_column_index': 1, + 'total_no_vat_column_index': 2, + }) + self.assertEqual( + 'Index "total_vat_column_index" is required', + cm2.exception.message, + ) + + # --- + with self.assertRaises(ValidationError) as cm3: + field.clean({ + 'mode': MODE_COMPUTE_VAT, + 'total_vat_column_index': 12, + 'total_no_vat_column_index': 2, + }) + self.assertEqual('Invalid index', cm3.exception.message) + + def test_field_required_choices(self): + clean = TotalsExtractorField(choices=self.choices).clean + msg_fmt = _('You have to select a column for «%(field)s».') + + with self.assertRaises(ValidationError) as cm1: + clean({ + 'mode': MODE_COMPUTE_TOTAL_VAT, + 'total_no_vat_column_index': '0', + 'vat_column_index': 2, + }) + self.assertListEqual( + [msg_fmt % {'field': _('Total without VAT')}], + [*cm1.exception], + ) + + # --- + with self.assertRaises(ValidationError) as cm2: + clean({ + 'mode': MODE_COMPUTE_TOTAL_VAT, + 'total_no_vat_column_index': 1, + 'vat_column_index': '0', + }) + self.assertListEqual( + [msg_fmt % {'field': _('VAT')}], + [*cm2.exception], + ) + + # --- + with self.assertRaises(ValidationError) as cm2: + clean({ + 'mode': MODE_COMPUTE_TOTAL_NO_VAT, + 'total_vat_column_index': '0', + 'vat_column_index': 2, + }) + self.assertListEqual( + [msg_fmt % {'field': _('Total with VAT')}], + [*cm2.exception], + ) + + def test_field_empty_not_required(self): + field = TotalsExtractorField(choices=self.choices, required=False) + + with self.assertNoException(): + extractor = field.clean({}) + + self.assertIsNone(extractor) + + @parameterized.expand([ + (MODE_COMPUTE_TOTAL_VAT, 'total_no_vat_column_index', 'vat_column_index'), + (MODE_COMPUTE_TOTAL_NO_VAT, 'total_vat_column_index', 'vat_column_index'), + (MODE_COMPUTE_VAT, 'total_no_vat_column_index', 'total_vat_column_index'), + ]) + def test_field_vat_creation(self, mode, index1, index2): + field = TotalsExtractorField(choices=self.choices) + self.assertIs(field.can_create_vat, False) + beware = _('Beware: you are not allowed to create new VAT values') + self.assertEqual(beware, str(field.help_text)) + + field.user = self.user + self.assertIs(field.can_create_vat, True) + self.assertEqual('', field.help_text) + + data = { + 'mode': mode, + index1: 1, + index2: 2, + } + + with self.assertNoException(): + extractor1 = field.clean(data) + + self.assertTrue(extractor1.create_vat) + + # --- + role = UserRole(name='Basic') + role.allowed_apps = ['creme_core'] + role.admin_4_apps = ['persons'] # creme_core + role.save() + + field.user = self.build_user(index=1, role=role) + self.assertIs(field.can_create_vat, False) + self.assertEqual(beware, str(field.help_text)) + + with self.assertNoException(): + extractor2 = field.clean(data) + + self.assertFalse(extractor2.create_vat) diff --git a/creme/billing/tests/test_invoice.py b/creme/billing/tests/test_invoice.py index 33f9e5721f..d225eeda8a 100644 --- a/creme/billing/tests/test_invoice.py +++ b/creme/billing/tests/test_invoice.py @@ -1355,9 +1355,13 @@ def test_delete_additional_info(self): self.assertIsNone(invoice.additional_info) @skipIfCustomAddress - def test_mass_import(self): + def test_mass_import_no_total(self): self.login() - self._aux_test_csv_import(Invoice, InvoiceStatus, number_help_text=False) + self._aux_test_csv_import_no_total(Invoice, InvoiceStatus, number_help_text=False) + + def test_mass_import_total_no_vat_n_vat(self): + self.login() + self._aux_test_csv_import_total_no_vat_n_vat(Invoice, InvoiceStatus) @skipIfCustomAddress def test_mass_import_update01(self): @@ -1387,12 +1391,70 @@ def test_mass_import_update03(self): ) @skipIfCustomAddress - def test_mass_import_update04(self): + def test_mass_import_update_total01(self): self.login() - self._aux_test_csv_import( + self._aux_test_csv_import_no_total( Invoice, InvoiceStatus, update=True, number_help_text=False, ) + def test_mass_import_update_total02(self): + user = self.login() + doc = self._build_csv_doc([('Bill #1', 'Nerv', 'Acme', '300', '15')]) + response = self.assertPOST200( + self._build_import_url(Invoice), + follow=True, + data={ + 'step': 1, + 'document': doc.id, + # has_header + + 'user': user.id, + 'key_fields': ['name'], + + 'name_colselect': 1, + 'number_colselect': 0, + + 'issuing_date_colselect': 0, + 'expiration_date_colselect': 0, + + 'status_colselect': 0, + 'status_defval': InvoiceStatus.objects.all()[0].pk, + + 'discount_colselect': 0, + 'discount_defval': '0', + + 'currency_colselect': 0, + 'currency_defval': Currency.objects.all()[0].pk, + + 'acceptation_date_colselect': 0, + + 'comment_colselect': 0, + 'additional_info_colselect': 0, + 'payment_terms_colselect': 0, + 'payment_type_colselect': 0, + + 'description_colselect': 0, + 'buyers_order_number_colselect': 0, # Invoice only... + + 'source_persons_organisation_colselect': 2, + 'target_persons_organisation_colselect': 3, + 'target_persons_contact_colselect': 0, + + 'totals_mode': '2', # Compute total with VAT + 'totals_total_no_vat_colselect': 4, + 'totals_vat_colselect': 5, + + # 'property_types', + # 'fixed_relations', + # 'dyn_relations', + }, + ) + self.assertFormError( + response.context['form'], + field='totals', + errors=_('You cannot compute totals in update mode.'), + ) + def test_brick01(self): user = self.login() source, target = self.create_orgas(user=user) diff --git a/creme/billing/tests/test_quote.py b/creme/billing/tests/test_quote.py index 4923e3313c..a37d8d8183 100644 --- a/creme/billing/tests/test_quote.py +++ b/creme/billing/tests/test_quote.py @@ -577,11 +577,11 @@ def test_delete_status(self): ) @skipIfCustomAddress - def test_mass_import01(self): + def test_mass_import_no_total01(self): self.login() - self._aux_test_csv_import(Quote, QuoteStatus) + self._aux_test_csv_import_no_total(Quote, QuoteStatus) - def test_mass_import02(self): + def test_mass_import_no_total02(self): "Source is managed." user = self.login() @@ -678,6 +678,10 @@ def test_mass_import02(self): self.assertNotEqual(number1, number2) + def test_mass_import_total_no_vat_n_vat(self): + self.login() + self._aux_test_csv_import_total_no_vat_n_vat(Quote, QuoteStatus) + @skipIfCustomAddress @skipIfCustomServiceLine def test_clone01(self): diff --git a/creme/billing/tests/test_sales_order.py b/creme/billing/tests/test_sales_order.py index d33e432e9a..ae637133a7 100644 --- a/creme/billing/tests/test_sales_order.py +++ b/creme/billing/tests/test_sales_order.py @@ -341,7 +341,7 @@ def test_delete_status(self): @skipIfCustomAddress def test_mass_import(self): self.login() - self._aux_test_csv_import(SalesOrder, SalesOrderStatus) + self._aux_test_csv_import_no_total(SalesOrder, SalesOrderStatus) @skipIfCustomAddress def test_mass_import_update(self): diff --git a/creme/creme_core/tests/utils/test_main.py b/creme/creme_core/tests/utils/test_main.py index e34e4e051c..9196c7379a 100644 --- a/creme/creme_core/tests/utils/test_main.py +++ b/creme/creme_core/tests/utils/test_main.py @@ -21,6 +21,7 @@ from creme.creme_core.models import FakeOrganisation, SetCredentials # from creme.creme_core.utils import find_first, split_filter from creme.creme_core.utils import ( + as_int, create_if_needed, ellipsis, ellipsis_multi, @@ -208,6 +209,15 @@ def test_date_2_dict(self): d = {'year': 2012, 'month': 6, 'day': 6} self.assertEqual(d, date_2_dict(date(**d))) + def test_as_int(self): + self.assertEqual(1, as_int('1')) + self.assertEqual(42, as_int('42')) + self.assertEqual(-12, as_int('-12')) + + self.assertEqual(0, as_int('foo')) + self.assertEqual(0, as_int([])) + self.assertEqual(-1, as_int('foo', default=-1)) + def test_int_2_roman(self): self.assertListEqual( [ diff --git a/creme/creme_core/utils/__init__.py b/creme/creme_core/utils/__init__.py index 3b0f59fcc9..c6e3f69864 100644 --- a/creme/creme_core/utils/__init__.py +++ b/creme/creme_core/utils/__init__.py @@ -231,6 +231,13 @@ def bool_as_html(b: bool) -> str: return f'{label}' +def as_int(value, default=0): + try: + return int(value) + except (ValueError, TypeError): + return default + + _I2R_NUMERAL_MAP = [ (1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'), (90, 'XC'), (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'),