diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 1789b0b16e..f3de65fdea 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -29,6 +29,6 @@ and expertise. form_hosting redis issues/index - upgrade-240 - upgrade-250 upgrade-300 + upgrade-250 + upgrade-240 diff --git a/docs/installation/upgrade-300.rst b/docs/installation/upgrade-300.rst index e8c34854c5..c1594a2c4d 100644 --- a/docs/installation/upgrade-300.rst +++ b/docs/installation/upgrade-300.rst @@ -4,16 +4,66 @@ Upgrade details to Open Forms 3.0.0 =================================== -Backwards compatibility in form import changes -============================================== -Removal of Object API object type convertion --------------------------------------------- +Open Forms 3.0 is a major version release that contains some breaking changes. As always +we try to limit the impact of breaking changes through automatic migrations, and this +release is no different, but there are some subtle changes in behaviour that you should +be aware of, as they may require additional manual actions. -With the UX changes of OF 2.8.0, the Object Api registration no longer lets you use -hyperlinks when configuring the object type. The usage of hyperlinks for the object type -is now also disallowed when importing a form. +.. contents:: Jump to + :depth: 1 + :local: -Previously the hyperlinks would be converted to the expected format, and saved as such. -The convertion will no-longer take place, and the 'to be imported' form is expected to -use the new UUID format for the object type. +Removal of price logic +====================== + +Price logic rules are removed in favour of setting the submission price via a form +variable and normal logic rules. The conversion is automatic. + +There is a slight change in behaviour. When no price logic rules matched the trigger +condition, the price set on the related product was used. This can lead to surprises +and wrong amounts being paid due to logical errors in the form itself. + +The new behaviour will (deliberately) cause a crash that will show to the end-user +as "something unexpectedly went wrong", since we refuse to make any (likely wrong) +assumptions about the amount that needs to be paid. Crash information is visible +in the error monitoring if that's set up correctly. + +.. note:: Existing, automatically converted forms are crash-free because we create an + explicit fallback logic rule that mimicks the old behaviour. + +Form components/fields changes +============================== + +Password component removed +-------------------------- + +The password component was deprecated a long time ago, and has now been removed. If you +need to replace it anywhere, use a regular textfield component instead, but you really +shouldn't be asking users for passwords. + +Removed import conversions +========================== + +For a number of changes, Open Forms ensured that old form exports could still be +imported and would automatically convert some data. Some of these conversion have been +removed. + +Removal of objecttype URL conversion in the Objects API registration options +---------------------------------------------------------------------------- + +Since the UX improvments in Open Forms 2.8.0 you can select the object type in a +dropdown, and under the hood we save the unique identifier rather than the full API +resource URL (which you used to have to copy-paste in a text field). The usage of +hyperlinks for the object type is now also disallowed when importing a form. + +Previously the hyperlinks would be converted to the expected format, and saved as such, +which was quite complex and not ideal for exports using the new format. We +recommend re-creating the exports on a newer version of Open Forms. + +Removal of legacy translations conversion +----------------------------------------- + +Old (from before Open Forms 2.4) form export files containing form field translations +in the legacy format are now ignored instead of converted to the new format. We +recommend re-creating the exports on a newer version of Open Forms. diff --git a/docs/manual/forms/basics.rst b/docs/manual/forms/basics.rst index 89b0ffb8bd..1c580737d2 100644 --- a/docs/manual/forms/basics.rst +++ b/docs/manual/forms/basics.rst @@ -199,7 +199,7 @@ product bevat een prijs die gebruikt kan worden als betaald moet worden voor het product. Betaling kan ingesteld worden door de juiste **Betaalprovider** te selecteren. -Er zijn drie manieren om de prijs van een inzending te bepalen: +Er zijn twee manieren om de prijs van een inzending te bepalen: **Gebruik de prijs van het gekoppeld product** @@ -226,10 +226,9 @@ te activeren. **Gebruik prijslogica** -Voor eenvoudige condities kan je prijslogic instellen. Onder een bepaalde conditie geldt -een bepaalde, vaste, prijs. Indien aan geen enkele conditie voldaan is, dan wordt de -prijs van het gekoppeld product gebruikt. De **Prijslogica** volgt verder dezelfde -regels als reguliere **Logica**. +.. versionremoved:: 3.0 + + De prijslogica is vervangen door gewone logica + gebruik van een variabele. Zie ook: :ref:`configuration_payment_index` diff --git a/src/openapi.yaml b/src/openapi.yaml index 7eb8b7adf9..2a1e223c33 100644 --- a/src/openapi.yaml +++ b/src/openapi.yaml @@ -2512,229 +2512,6 @@ paths: $ref: '#/components/headers/X-Is-Form-Designer' Content-Language: $ref: '#/components/headers/Content-Language' - /api/v2/forms/{uuid_or_slug}/price-logic-rules: - get: - operationId: forms_price_logic_rules_list - description: List all price logic rules defined for a form. - summary: List price logic rules - parameters: - - in: path - name: uuid_or_slug - schema: - type: integer - description: A unique integer value identifying this form. - required: true - tags: - - logic-rules - security: - - tokenAuth: [] - - cookieAuth: [] - responses: - '200': - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/FormPriceLogic' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/Exception' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/Exception' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' - '404': - content: - application/json: - schema: - $ref: '#/components/schemas/Exception' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' - '405': - content: - application/json: - schema: - $ref: '#/components/schemas/Exception' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' - put: - operationId: forms_price_logic_rules_update - description: By sending a list of FormPriceLogic to this endpoint, all the FormPriceLogic - related to the form will be replaced with the data sent to the endpoint. - summary: Bulk configure price logic rules - parameters: - - in: header - name: X-CSP-Nonce - schema: - type: string - description: The value of the CSP nonce generated by the page embedding the - SDK. If provided, fields containing rich text from WYSIWYG editors will - be post-processed to allow inline styles with the provided nonce. If the - embedding page emits a `style-src` policy containing `unsafe-inline`, then - you can omit this header without losing functionality. We recommend favouring - the nonce mechanism though. - - in: path - name: uuid_or_slug - schema: - type: integer - description: A unique integer value identifying this form. - required: true - tags: - - logic-rules - requestBody: - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/FormPriceLogic' - required: true - security: - - tokenAuth: [] - - cookieAuth: [] - responses: - '200': - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/FormPriceLogic' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ValidationError' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/Exception' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/Exception' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' - '404': - content: - application/json: - schema: - $ref: '#/components/schemas/Exception' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' - '405': - content: - application/json: - schema: - $ref: '#/components/schemas/Exception' - description: '' - headers: - X-Session-Expires-In: - $ref: '#/components/headers/X-Session-Expires-In' - X-CSRFToken: - $ref: '#/components/headers/X-CSRFToken' - X-Is-Form-Designer: - $ref: '#/components/headers/X-Is-Form-Designer' - Content-Language: - $ref: '#/components/headers/Content-Language' /api/v2/forms/{uuid_or_slug}/variables: get: operationId: forms_variables_list @@ -8471,36 +8248,6 @@ components: title: Explanation template [nl] description: Content that will be shown on the start page of the form, below the title and above the log in text. - FormPriceLogic: - type: object - properties: - uuid: - type: string - format: uuid - readOnly: true - url: - type: string - format: uri - readOnly: true - form: - type: string - format: uri - description: Form to which the pricing JSON logic applies. - jsonLogicTrigger: - title: JSON logic - description: The trigger expression to determine if the actions should execute - or not. Note that this must be a valid JsonLogic expression, and the first - operand must be a reference to a variable in the form. - price: - type: string - format: decimal - pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ - required: - - form - - jsonLogicTrigger - - price - - url - - uuid FormRegistrationBackend: type: object properties: diff --git a/src/openforms/forms/api/serializers/__init__.py b/src/openforms/forms/api/serializers/__init__.py index 8e7ed98316..33d1fb91c6 100644 --- a/src/openforms/forms/api/serializers/__init__.py +++ b/src/openforms/forms/api/serializers/__init__.py @@ -5,11 +5,9 @@ from .form_variable import FormVariableListSerializer, FormVariableSerializer from .form_version import FormVersionSerializer from .logic.form_logic import FormLogicSerializer -from .logic.form_logic_price import FormPriceLogicSerializer __all__ = [ "FormLogicSerializer", - "FormPriceLogicSerializer", "FormSerializer", "FormExportSerializer", "FormImportSerializer", diff --git a/src/openforms/forms/api/serializers/logic/form_logic_price.py b/src/openforms/forms/api/serializers/logic/form_logic_price.py deleted file mode 100644 index 760a587258..0000000000 --- a/src/openforms/forms/api/serializers/logic/form_logic_price.py +++ /dev/null @@ -1,22 +0,0 @@ -from openforms.api.serializers import ListWithChildSerializer -from openforms.forms.api.serializers.logic.form_logic import FormLogicBaseSerializer -from openforms.forms.models import FormPriceLogic - - -class FormPriceLogicListSerializer(ListWithChildSerializer): - child_serializer_class = "openforms.forms.api.serializers.logic.form_logic_price.FormPriceLogicSerializer" - - -class FormPriceLogicSerializer(FormLogicBaseSerializer): - class Meta(FormLogicBaseSerializer.Meta): - model = FormPriceLogic - list_serializer_class = FormPriceLogicListSerializer - fields = FormLogicBaseSerializer.Meta.fields + ("price",) - extra_kwargs = { - **FormLogicBaseSerializer.Meta.extra_kwargs, - "url": { - "view_name": "api:form-price-logic-rules", - "lookup_field": "uuid", - "lookup_url_kwarg": "uuid_or_slug", - }, - } diff --git a/src/openforms/forms/api/viewsets.py b/src/openforms/forms/api/viewsets.py index 2f6617ad1b..e0a81e87e2 100644 --- a/src/openforms/forms/api/viewsets.py +++ b/src/openforms/forms/api/viewsets.py @@ -48,7 +48,6 @@ FormDefinitionSerializer, FormImportSerializer, FormLogicSerializer, - FormPriceLogicSerializer, FormSerializer, FormStepSerializer, FormVariableListSerializer, @@ -56,7 +55,6 @@ FormVersionSerializer, ) from .serializers.logic.form_logic import FormLogicListSerializer -from .serializers.logic.form_logic_price import FormPriceLogicListSerializer @extend_schema( @@ -294,23 +292,6 @@ def configuration(self, request: Request, *args, **kwargs): status.HTTP_405_METHOD_NOT_ALLOWED: ExceptionSerializer, }, ), - price_logic_rules_bulk_update=extend_schema( - summary=_("Bulk configure price logic rules"), - description=_( - "By sending a list of FormPriceLogic to this endpoint, all the FormPriceLogic related to the form will be " - "replaced with the data sent to the endpoint." - ), - tags=["logic-rules"], - request=FormPriceLogicListSerializer, - responses={ - status.HTTP_200_OK: FormPriceLogicListSerializer, - status.HTTP_400_BAD_REQUEST: ValidationErrorSerializer, - status.HTTP_401_UNAUTHORIZED: ExceptionSerializer, - status.HTTP_403_FORBIDDEN: ExceptionSerializer, - status.HTTP_404_NOT_FOUND: ExceptionSerializer, - status.HTTP_405_METHOD_NOT_ALLOWED: ExceptionSerializer, - }, - ), ) class FormViewSet(viewsets.ModelViewSet): """ @@ -364,8 +345,6 @@ def get_queryset(self): "variables_list", "logic_rules_bulk_update", "logic_rules_list", - "price_logic_rules_bulk_update", - "price_logic_rules_list", ): queryset = queryset.select_related(None).prefetch_related(None) @@ -615,61 +594,6 @@ def logic_rules_list(self, request, *args, **kwargs): ) return Response(serializer.data, status=status.HTTP_200_OK) - @action( - detail=True, - methods=["put"], - url_path="price-logic-rules", - url_name="price-logic-rules", - ) - @transaction.atomic - def price_logic_rules_bulk_update(self, request, *args, **kwargs): - form = self.get_object() - price_logic_rules = form.formpricelogic_set.all() - # We expect that all the price logic rules associated with a form come in the request. - # So we can delete any existing rule because they will be replaced. - price_logic_rules.delete() - - serializer = FormPriceLogicSerializer( - data=request.data, - many=True, - context={ - "request": request, - "form": form, - # context for :class:`openforms.api.fields.RelatedFieldFromContext` lookups - "forms": {str(form.uuid): form}, - "form_variables": FormVariableWrapper(form), - }, - ) - serializer.is_valid(raise_exception=True) - serializer.save() - - return Response(serializer.data, status=status.HTTP_200_OK) - - @extend_schema( - summary=_("List price logic rules"), - description=_("List all price logic rules defined for a form."), - tags=["logic-rules"], - request=FormPriceLogicListSerializer, - responses={ - status.HTTP_200_OK: FormPriceLogicListSerializer, - status.HTTP_401_UNAUTHORIZED: ExceptionSerializer, - status.HTTP_403_FORBIDDEN: ExceptionSerializer, - status.HTTP_404_NOT_FOUND: ExceptionSerializer, - status.HTTP_405_METHOD_NOT_ALLOWED: ExceptionSerializer, - }, - ) - @price_logic_rules_bulk_update.mapping.get - def price_logic_rules_list(self, request, *args, **kwargs): - form = self.get_object() - price_logic_rules = form.formpricelogic_set.all() - - serializer = FormPriceLogicSerializer( - instance=price_logic_rules, - many=True, - context={"request": request, "form": form}, - ) - return Response(serializer.data, status=status.HTTP_200_OK) - FormViewSet.__doc__ = inspect.getdoc(FormViewSet).format( admin_fields=_FORM_ADMIN_FIELDS_MARKDOWN diff --git a/src/openforms/forms/migrations/0106_convert_price_logic_rules.py b/src/openforms/forms/migrations/0106_convert_price_logic_rules.py new file mode 100644 index 0000000000..885c92a45e --- /dev/null +++ b/src/openforms/forms/migrations/0106_convert_price_logic_rules.py @@ -0,0 +1,107 @@ +# Generated by Django 4.2.16 on 2024-11-25 15:32 +from decimal import Decimal + +from django.db import migrations +from django.db.migrations.state import StateApps + +from openforms.forms.constants import LogicActionTypes +from openforms.variables.constants import FormVariableDataTypes, FormVariableSources + +VARIABLE_NAME = "Total price" +VARIABLE_KEY = "totalPrice" + + +def _assignment_action(key: str, value: Decimal): + return { + "variable": key, + "action": { + "type": LogicActionTypes.variable, + "value": str(value), + }, + } + + +def convert_price_logic_rules_to_price_variable(apps: StateApps, _): + """ + For each form that has price logic rules, create a variable to hold the price and + add normal logic rules. + """ + Form = apps.get_model("forms", "Form") + forms_with_pricelogic = ( + Form.objects.filter(formpricelogic__isnull=False) + .exclude(product__isnull=True) + .distinct() + ) + + for form in forms_with_pricelogic.iterator(): + product = form.product + rules = form.formpricelogic_set.all() + + # create a variable to hold the result. + variable_keys = set(form.formvariable_set.values_list("key", flat=True)) + variable_key = VARIABLE_KEY + variable_name = VARIABLE_NAME + counter = 0 + while variable_key in variable_keys: + counter += 1 + variable_key = f"{variable_key}{counter}" + variable_name = f"{variable_name}{counter}" + if counter > 100: + raise RuntimeError( + "Could not generate a unique key without looping too long" + ) + + price_variable = form.formvariable_set.create( + form_definition=None, + name=variable_name, + key=variable_key, + source=FormVariableSources.user_defined, + data_type=FormVariableDataTypes.float, + ) + form.price_variable_key = price_variable.key + form.save() + + max_order = ( + last_rule.order + if (last_rule := form.formlogic_set.order_by("order").last()) + else 0 + ) + + # set up regular logic rules for each price logic rule + for rule in rules: + max_order += 1 + form.formlogic_set.create( + description="Converted price logic rule", + order=max_order, + is_advanced=True, + json_logic_trigger=rule.json_logic_trigger, + actions=[_assignment_action(form.price_variable_key, rule.price)], + ) + + # create one fallback rule in case none of the triggers hit + composite_negated_trigger = { + "!": {"or": [rule.json_logic_trigger for rule in rules]} + } + max_order += 1 + form.formlogic_set.create( + description="Converted price logic rule", + order=max_order, + is_advanced=True, + json_logic_trigger=composite_negated_trigger, + actions=[_assignment_action(form.price_variable_key, product.price)], + ) + + rules.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("forms", "0105_alter_form_all_submissions_removal_limit_and_more"), + ] + + operations = [ + migrations.RunPython( + convert_price_logic_rules_to_price_variable, migrations.RunPython.noop + ), + ] diff --git a/src/openforms/forms/models/pricing_logic.py b/src/openforms/forms/models/pricing_logic.py index e0fcc70b88..a5a40af393 100644 --- a/src/openforms/forms/models/pricing_logic.py +++ b/src/openforms/forms/models/pricing_logic.py @@ -16,15 +16,7 @@ class FormPriceLogic(models.Model): The data model is similar to :class:`openforms.forms.models.FormLogic`. - .. todo:: - - Document logic evaluation - rules are evaluated and as soon as one matches, - that's the winner. If multiple rules match, the first one wins. - - .. todo:: - - Support complex conditions (AND/OR them together). This is a broader logic - editing/evaluation issue that applies for regular form logic too. + .. warning:: This feature is no longer supported. """ uuid = models.UUIDField(_("UUID"), unique=True, default=_uuid.uuid4) diff --git a/src/openforms/forms/tests/factories.py b/src/openforms/forms/tests/factories.py index a747d15051..73ad058afb 100644 --- a/src/openforms/forms/tests/factories.py +++ b/src/openforms/forms/tests/factories.py @@ -2,11 +2,12 @@ import factory +from openforms.forms.constants import LogicActionTypes from openforms.products.tests.factories import ProductFactory from openforms.registrations.registry import register as registration_registry from openforms.variables.constants import FormVariableDataTypes, FormVariableSources -from ..models import FormDefinition, FormStep, FormVariable +from ..models import Form, FormDefinition, FormStep, FormVariable from ..utils import form_to_json @@ -64,6 +65,45 @@ def registration_backend(form, create, extracted, **kwargs): else FormRegistrationBackendFactory.build )(form=form, backend=extracted, **kwargs) + @factory.post_generation + def price_logic( + form: Form, # pyright: ignore[reportGeneralTypeIssues] + create, + extracted, + **kwargs, + ): + """ + Configure the form to evaluate the price using logic rules and read it from + the respective variable. + + .. note:: submissions for the form must call + ``openforms.submissions.utils.persist_user_defined_variables`` + + .. note:: forms with price logic must have at least one step + """ + if not (extracted or kwargs): + return + + if not create: + raise ValueError( + "Price logic can only be specified with the create strategy" + ) + + form_logic = extracted or FormLogicFactory.create( + form=form, + json_logic_trigger=kwargs.get("json_logic_trigger", True), + for_submission_price=True, + price_variable=kwargs.get("price_variable", "totalPrice"), + price_value=kwargs.get("price_value", 5.00), + order=kwargs.get( + "order", 1000 + ), # should typically be one of the last rules evaluated + ) + variable_key = form_logic.actions[0]["variable"] + FormVariableFactory.create(form=form, for_price=True, key=variable_key) + form.price_variable_key = variable_key + form.save() + class FormRegistrationBackendFactory(factory.django.DjangoModelFactory): key = factory.Sequence(lambda n: f"backend{n}") @@ -175,26 +215,29 @@ def post(obj, create, extracted, **kwargs): class FormLogicFactory(factory.django.DjangoModelFactory): json_logic_trigger = {"==": [{"var": "test-key"}, 1]} - actions = [{"action": {"type": "disable-next"}}] class Meta: model = "forms.FormLogic" + class Params: + # generate a logic rule that sets a submission price + for_submission_price = False + price_variable = "totalPrice" + price_value = 5.00 # literal value or JSON logic expression -class FormPriceLogicFactory(factory.django.DjangoModelFactory): - form = factory.SubFactory(FormFactory) - json_logic_trigger = {"==": [{"var": "test-key"}, 1]} - price = factory.Faker( - "pydecimal", - left_digits=2, - right_digits=2, - positive=True, - min_value=5.00, - max_value=100.00, - ) - - class Meta: - model = "forms.FormPriceLogic" + @factory.lazy_attribute + def actions(self): + if self.for_submission_price: # type: ignore + return [ + { + "variable": self.price_variable, # type: ignore + "action": { + "type": LogicActionTypes.variable, + "value": self.price_value, # type: ignore + }, + } + ] + return [{"action": {"type": LogicActionTypes.disable_next}}] class FormVariableFactory(factory.django.DjangoModelFactory): @@ -213,6 +256,13 @@ class Params: source=FormVariableSources.user_defined, form_definition=None, ) + for_price = factory.Trait( + name="Total price", + key="totalPrice", + form_definition=None, + source=FormVariableSources.user_defined, + data_type=FormVariableDataTypes.float, + ) @factory.post_generation def form_definition(obj, create, extracted, **kwargs): diff --git a/src/openforms/forms/tests/test_api_form_price_logic_bulk.py b/src/openforms/forms/tests/test_api_form_price_logic_bulk.py deleted file mode 100644 index 5ad4fffd2f..0000000000 --- a/src/openforms/forms/tests/test_api_form_price_logic_bulk.py +++ /dev/null @@ -1,192 +0,0 @@ -from decimal import Decimal - -from rest_framework import status -from rest_framework.reverse import reverse, reverse_lazy -from rest_framework.test import APITestCase - -from openforms.accounts.tests.factories import SuperUserFactory, UserFactory - -from ..models import FormPriceLogic -from .factories import FormFactory, FormPriceLogicFactory - - -class FormPriceLogicBulkAPITests(APITestCase): - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.superuser = SuperUserFactory.create() - cls.form = FormFactory.create( - generate_minimal_setup=True, - formstep__form_definition__configuration={ - "components": [ - { - "type": "textfield", - "key": "step1_textfield1", - } - ] - }, - ) - cls.form_url = reverse( - "api:form-detail", kwargs={"uuid_or_slug": cls.form.uuid} - ) - - def test_auth_required(self): - response = self.client.get( - reverse_lazy( - "api:form-price-logic-rules", kwargs={"uuid_or_slug": self.form.uuid} - ) - ) - - self.assertEqual(status.HTTP_401_UNAUTHORIZED, response.status_code) - - def test_staff_user_required(self): - user = UserFactory.create(is_staff=False) - self.client.force_authenticate(user) - - response = self.client.get( - reverse_lazy( - "api:form-price-logic-rules", kwargs={"uuid_or_slug": self.form.uuid} - ) - ) - - self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) - - def test_list_and_filter_price_logic(self): - self.client.force_authenticate(self.superuser) - fpl1, fpl2 = FormPriceLogicFactory.create_batch(2) - assert fpl1.form != fpl2.form - - url = reverse( - "api:form-price-logic-rules", kwargs={"uuid_or_slug": fpl1.form.uuid} - ) - response = self.client.get(url) - - self.assertEqual(status.HTTP_200_OK, response.status_code) - response_data = response.json() - self.assertEqual(len(response_data), 1) - self.assertEqual(response_data[0]["uuid"], str(fpl1.uuid)) - - def test_create_price_logic(self): - self.client.force_authenticate(user=self.superuser) - price_logic_data = [ - { - "form": f"http://testserver{self.form_url}", - "json_logic_trigger": { - "==": [ - {"var": "step1_textfield1"}, - "test", - ] - }, - "price": "15.00", - } - ] - - url = reverse( - "api:form-price-logic-rules", kwargs={"uuid_or_slug": self.form.uuid} - ) - response = self.client.put(url, data=price_logic_data) - - self.assertEqual(status.HTTP_200_OK, response.status_code) - price_logics_qs = FormPriceLogic.objects.all() - self.assertEqual(price_logics_qs.count(), 1) - price_logic = price_logics_qs.get() - self.assertEqual(price_logic.form, self.form) - self.assertEqual(price_logic.price, Decimal("15.00")) - - def test_create_logic_with_dates(self): - self.client.force_authenticate(user=self.superuser) - form = FormFactory.create( - generate_minimal_setup=True, - formstep__form_definition__configuration={ - "components": [ - { - "type": "datetime", - "key": "dateOfBirth", - } - ] - }, - ) - form_url = reverse("api:form-detail", kwargs={"uuid_or_slug": form.uuid}) - price_logic_data = [ - { - "form": f"http://testserver{form_url}", - "json_logic_trigger": { - ">": [ - {"date": {"var": "dateOfBirth"}}, - {"-": [{"today": []}, {"rdelta": [18]}]}, - ] - }, - "price": "15.00", - } - ] - - url = reverse("api:form-price-logic-rules", kwargs={"uuid_or_slug": form.uuid}) - response = self.client.put(url, data=price_logic_data) - - self.assertEqual(status.HTTP_200_OK, response.status_code) - - def test_delete_price_logic(self): - self.client.force_authenticate(user=self.superuser) - price_rule = FormPriceLogicFactory.create(form__generate_minimal_setup=True) - - url = reverse( - "api:form-price-logic-rules", kwargs={"uuid_or_slug": price_rule.form.uuid} - ) - - response = self.client.put(url, data=[]) - - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertFalse(FormPriceLogic.objects.exists()) - - def test_invalid_logic_trigger(self): - self.client.force_authenticate(user=self.superuser) - price_logic_data = [ - { - "form": f"http://testserver{self.form_url}", - "json_logic_trigger": { - "invalid_op": [ - {"var": "step1_textfield1"}, - "hide step 1", - ] - }, - "price": "123.15", - } - ] - - url = reverse( - "api:form-price-logic-rules", kwargs={"uuid_or_slug": self.form.uuid} - ) - response = self.client.put(url, data=price_logic_data) - - self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) - self.assertEqual( - response.json()["invalidParams"][0]["name"], - "0.jsonLogicTrigger", - ) - - def test_invalid_price(self): - self.client.force_authenticate(user=self.superuser) - price_logic_data = [ - { - "form": f"http://testserver{self.form_url}", - "json_logic_trigger": { - "==": [ - {"var": "step1_textfield1"}, - "hide step 1", - ] - }, - "price": "", - } - ] - - url = reverse( - "api:form-price-logic-rules", kwargs={"uuid_or_slug": self.form.uuid} - ) - response = self.client.put(url, data=price_logic_data) - - self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) - self.assertEqual( - response.json()["invalidParams"][0]["name"], - "0.price", - ) diff --git a/src/openforms/forms/tests/test_migrations.py b/src/openforms/forms/tests/test_migrations.py new file mode 100644 index 0000000000..95676f0d8a --- /dev/null +++ b/src/openforms/forms/tests/test_migrations.py @@ -0,0 +1,210 @@ +from decimal import Decimal + +from django.db.migrations.state import StateApps + +from openforms.submissions.form_logic import check_submission_logic +from openforms.submissions.models import ( + Submission, + SubmissionStep, + SubmissionValueVariable, +) +from openforms.submissions.pricing import get_submission_price +from openforms.utils.tests.test_migrations import TestMigrations +from openforms.variables.constants import FormVariableDataTypes, FormVariableSources + + +def _persist_user_defined_variables(submission): + # inspired by openforms.submissions.utils.persist_user_defined_variables + data = submission.data + check_submission_logic(submission, data) + state = submission.load_submission_value_variables_state() + variables = state.variables + + user_defined_vars_data = { + variable.key: variable.value + for variable in variables.values() + if variable.form_variable + and variable.form_variable.source == FormVariableSources.user_defined + } + + if user_defined_vars_data: + SubmissionValueVariable.objects.bulk_create_or_update_from_data( + user_defined_vars_data, submission + ) + + +class FormLogicMigrationTests(TestMigrations): + app = "forms" + migrate_from = "0105_alter_form_all_submissions_removal_limit_and_more" + migrate_to = "0106_convert_price_logic_rules" + + def setUpBeforeMigration(self, apps: StateApps): + # set up some variants that will each be hit for different submissions. After + # migrating to form variable pricing, the result must be the same. + Product = apps.get_model("products", "Product") + Form = apps.get_model("forms", "Form") + FormDefinition = apps.get_model("forms", "FormDefinition") + FormStep = apps.get_model("forms", "FormStep") + FormPriceLogic = apps.get_model("forms", "FormPriceLogic") + FormVariable = apps.get_model("forms", "FormVariable") + + product = Product.objects.create(name="Test product", price=Decimal("4.12")) + fd = FormDefinition.objects.create( + name="Pricing tests", configuration={"components": []} + ) + form = Form.objects.create(name="Pricing tests", product=product, slug="step-1") + form_step = FormStep.objects.create(form=form, form_definition=fd, order=1) + amount_variable = FormVariable.objects.create( + form=form, + name="Amount", + key="amount", + source=FormVariableSources.user_defined, + data_type=FormVariableDataTypes.float, + initial_value=1.0, + ) + FormPriceLogic.objects.create( + form=form, + json_logic_trigger={"==": [{"var": "amount"}, 3]}, + price=Decimal("11.99"), + ) + FormPriceLogic.objects.create( + form=form, + json_logic_trigger={"==": [{"var": "amount"}, 5]}, + price=Decimal("19.99"), + ) + + # we deliberately use the real models here to get access to the properties and + # custom methods so that we can call get_submission_price. This requires + # migrations to be properly orchestrated so that no schema migrations in the + # submissions app happen at the wrong time! It could be a possible future + # failure point. + + submission1 = Submission.objects.create(form_id=form.id) + SubmissionStep.objects.create(submission=submission1, form_step_id=form_step.id) + self.submission1_pk = submission1.pk + SubmissionValueVariable.objects.create( + submission=submission1, + form_variable_id=amount_variable.id, + key="amount", + value=1.0, + ) + price1 = get_submission_price(submission1) + assert price1 == Decimal("4.12") + + submission2 = Submission.objects.create(form_id=form.id) + SubmissionStep.objects.create(submission=submission2, form_step_id=form_step.id) + self.submission2_pk = submission2.pk + SubmissionValueVariable.objects.create( + submission=submission2, + form_variable_id=amount_variable.id, + key="amount", + value=3, + ) + price2 = get_submission_price(submission2) + assert price2 == Decimal("11.99") + + submission3 = Submission.objects.create(form_id=form.id) + SubmissionStep.objects.create(submission=submission3, form_step_id=form_step.id) + self.submission3_pk = submission3.pk + SubmissionValueVariable.objects.create( + submission=submission3, + form_variable_id=amount_variable.id, + key="amount", + value=5, + ) + price3 = get_submission_price(submission3) + assert price3 == Decimal("19.99") + + def test_prices_still_the_same_after_migration(self): + with self.subTest("submission 1"): + submission1 = Submission.objects.get(pk=self.submission1_pk) + _persist_user_defined_variables(submission1) + + price1 = get_submission_price(submission1) + + self.assertEqual(price1, Decimal("4.12")) + + with self.subTest("submission 2"): + submission2 = Submission.objects.get(pk=self.submission2_pk) + _persist_user_defined_variables(submission2) + + price2 = get_submission_price(submission2) + + self.assertEqual(price2, Decimal("11.99")) + + with self.subTest("submission 3"): + submission3 = Submission.objects.get(pk=self.submission3_pk) + _persist_user_defined_variables(submission3) + + price3 = get_submission_price(submission3) + + self.assertEqual(price3, Decimal("19.99")) + + def test_price_variable_created(self): + Form = self.apps.get_model("forms", "Form") + form = Form.objects.get() + + variables = {variable.key: variable for variable in form.formvariable_set.all()} + + self.assertIn("totalPrice", variables) + variable = variables["totalPrice"] + self.assertEqual(variable.data_type, FormVariableDataTypes.float) + self.assertEqual(variable.source, FormVariableSources.user_defined) + + +class DuplicatePriceVariableMigrationTests(TestMigrations): + app = "forms" + migrate_from = "0105_alter_form_all_submissions_removal_limit_and_more" + migrate_to = "0106_convert_price_logic_rules" + + def setUpBeforeMigration(self, apps: StateApps): + # set up some variants that will each be hit for different submissions. After + # migrating to form variable pricing, the result must be the same. + Product = apps.get_model("products", "Product") + Form = apps.get_model("forms", "Form") + FormDefinition = apps.get_model("forms", "FormDefinition") + FormStep = apps.get_model("forms", "FormStep") + FormPriceLogic = apps.get_model("forms", "FormPriceLogic") + FormVariable = apps.get_model("forms", "FormVariable") + + product = Product.objects.create(name="Test product", price=Decimal("4.12")) + fd = FormDefinition.objects.create( + name="Pricing tests", configuration={"components": []} + ) + form = Form.objects.create(name="Pricing tests", product=product, slug="step-1") + FormStep.objects.create(form=form, form_definition=fd, order=1) + FormPriceLogic.objects.create( + form=form, + json_logic_trigger={"==": [{"var": "amount"}, 3]}, + price=Decimal("11.99"), + ) + FormVariable.objects.create( + form=form, + name="Amount", + key="amount", + source=FormVariableSources.user_defined, + data_type=FormVariableDataTypes.float, + initial_value=1.0, + ) + # causes conflicts + FormVariable.objects.create( + form=form, + name="Total price", + key="totalPrice", + source=FormVariableSources.user_defined, + data_type=FormVariableDataTypes.float, + initial_value=10.0, + ) + + def test_price_variable_created(self): + Form = self.apps.get_model("forms", "Form") + form = Form.objects.get() + + variables = {variable.key: variable for variable in form.formvariable_set.all()} + + self.assertIn("totalPrice", variables) # pre-existing + self.assertIn("totalPrice1", variables) # newly created + variable = variables["totalPrice1"] + self.assertEqual(variable.data_type, FormVariableDataTypes.float) + self.assertEqual(variable.source, FormVariableSources.user_defined) + self.assertEqual(form.price_variable_key, "totalPrice1") diff --git a/src/openforms/js/compiled-lang/en.json b/src/openforms/js/compiled-lang/en.json index c0c5ce575d..f0f116a9aa 100644 --- a/src/openforms/js/compiled-lang/en.json +++ b/src/openforms/js/compiled-lang/en.json @@ -1383,12 +1383,6 @@ "value": "plus" } ], - "BrsG4w": [ - { - "type": 0, - "value": "Then the price is" - } - ], "BvRBef": [ { "type": 0, @@ -2909,12 +2903,6 @@ "value": "Output mapping" } ], - "QAbqb6": [ - { - "type": 0, - "value": "Add rule" - } - ], "QDBI2H": [ { "type": 0, @@ -4439,12 +4427,6 @@ "value": "Attachments" } ], - "eJEu3L": [ - { - "type": 0, - "value": "Are you sure you want to delete this rule?" - } - ], "eLDxD5": [ { "type": 0, @@ -6665,12 +6647,6 @@ "value": "Plugin configuration: Ogone legacy" } ], - "zeXZzX": [ - { - "type": 0, - "value": "Use logic rules to determine the price" - } - ], "zwOwrD": [ { "type": 0, diff --git a/src/openforms/js/compiled-lang/nl.json b/src/openforms/js/compiled-lang/nl.json index 6b5d2f2d9f..6e50fb8430 100644 --- a/src/openforms/js/compiled-lang/nl.json +++ b/src/openforms/js/compiled-lang/nl.json @@ -1387,12 +1387,6 @@ "value": "plus" } ], - "BrsG4w": [ - { - "type": 0, - "value": "dan is de prijs" - } - ], "BvRBef": [ { "type": 0, @@ -2926,12 +2920,6 @@ "value": "Uitvoerparameters" } ], - "QAbqb6": [ - { - "type": 0, - "value": "Regel toevoegen" - } - ], "QDBI2H": [ { "type": 0, @@ -4461,12 +4449,6 @@ "value": "Bijlagen" } ], - "eJEu3L": [ - { - "type": 0, - "value": "Weet u zeker dat u deze regel wil verwijderen?" - } - ], "eLDxD5": [ { "type": 0, @@ -6693,12 +6675,6 @@ "value": "Plugin-instellingen: Ogone legacy" } ], - "zeXZzX": [ - { - "type": 0, - "value": "Gebruik prijsregels om de prijs te bepalen" - } - ], "zwOwrD": [ { "type": 0, diff --git a/src/openforms/js/components/admin/form_design/PriceLogic.js b/src/openforms/js/components/admin/form_design/PriceLogic.js index 650c0ffa09..c4c9d1ece2 100644 --- a/src/openforms/js/components/admin/form_design/PriceLogic.js +++ b/src/openforms/js/components/admin/form_design/PriceLogic.js @@ -1,30 +1,14 @@ import PropTypes from 'prop-types'; -import React, {useContext, useState} from 'react'; +import React, {useState} from 'react'; import {FormattedMessage, defineMessage, useIntl} from 'react-intl'; -import ButtonContainer from 'components/admin/forms/ButtonContainer'; import Field from 'components/admin/forms/Field'; import Fieldset from 'components/admin/forms/Fieldset'; import FormRow from 'components/admin/forms/FormRow'; -import {NumberInput} from 'components/admin/forms/Inputs'; import Select from 'components/admin/forms/Select'; -import {ValidationErrorContext} from 'components/admin/forms/ValidationErrors'; import VariableSelection from 'components/admin/forms/VariableSelection'; -import {DeleteIcon} from 'components/admin/icons'; import {getTranslatedChoices} from 'utils/i18n'; -import DSLEditorNode from './logic/DSLEditorNode'; -import Trigger from './logic/Trigger'; -import {parseValidationErrors} from './utils'; - -export const EMPTY_PRICE_RULE = { - uuid: '', - _generatedId: '', // consumers should generate this, as it's used for the React key prop if no uuid exists - form: '', - jsonLogicTrigger: {}, - price: '', -}; - const PRICING_MODES = [ [ 'static', @@ -40,13 +24,6 @@ const PRICING_MODES = [ defaultMessage: 'Use a variable for the price', }), ], - [ - 'dynamic', - defineMessage({ - description: 'dynamic pricing mode label', - defaultMessage: 'Use logic rules to determine the price', - }), - ], ]; const PricingMode = ({mode = 'static', onChange}) => { @@ -61,57 +38,21 @@ PricingMode.propTypes = { onChange: PropTypes.func.isRequired, }; -export const PriceLogic = ({variableKey, rules = [], onChange, onDelete, onAdd, onFieldChange}) => { - const initialPricingMode = - variableKey !== '' ? 'variable' : rules.length > 0 ? 'dynamic' : 'static'; +export const PriceLogic = ({variableKey, onFieldChange}) => { + const initialPricingMode = variableKey !== '' ? 'variable' : 'static'; const [pricingMode, setPricingMode] = useState(initialPricingMode); - const validationErrors = parseValidationErrors(useContext(ValidationErrorContext), 'priceRules'); - - // TODO: de-duplicate/validate duplicate rules (identical triggers?) - const onPricingModeChange = event => { const {value} = event.target; - const resetVariableKey = () => { - if (variableKey) { - onFieldChange({target: {name: 'form.priceVariableKey', value: ''}}); - } - }; - - const resetRules = () => { - if (rules.length > 0) { - // XXX: iterate in reverse so we delete all rules by removing the last one - // every time. - // State updates in event handlers are batched by React, so removing index 0, 1,... - // in success causes issues since the local `rules` does no langer match the - // parent component state - draft.priceRules.length !== rules.length. - // By reversing, we essentially pop the last element every time which - // works around this. - const maxIndex = rules.length - 1; - for (let offset = 0; offset < rules.length; offset++) { - onDelete(maxIndex - offset); - } - } - }; - switch (value) { case 'variable': { - resetRules(); - break; - } - case 'dynamic': { - // toggle from static to dynamic -> ensure at least one rule exists - if (rules.length === 0) { - onAdd(); - } - resetVariableKey(); break; } case 'static': { - // toggle from dynamic to static -> delete all the rules - resetRules(); - resetVariableKey(); + if (variableKey) { + onFieldChange({target: {name: 'form.priceVariableKey', value: ''}}); + } break; } } @@ -138,22 +79,6 @@ export const PriceLogic = ({variableKey, rules = [], onChange, onDelete, onAdd, - {rules.map((rule, index) => ( - - ))} - - {pricingMode === 'dynamic' && ( - - - - )} - {pricingMode === 'variable' && ( { - const intl = useIntl(); - const deleteConfirmMessage = intl.formatMessage({ - description: 'Price rule deletion confirm message', - defaultMessage: 'Are you sure you want to delete this rule?', - }); - return ( -
-
- -
- -
- - - -  €  - - - -
-
- ); -}; - -Rule.propTypes = { - jsonLogicTrigger: PropTypes.object, - price: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - onChange: PropTypes.func.isRequired, - onDelete: PropTypes.func.isRequired, - errors: PropTypes.object, -}; - export default PriceLogic; diff --git a/src/openforms/js/components/admin/form_design/data/complete-form.js b/src/openforms/js/components/admin/form_design/data/complete-form.js index 39cc41f608..d1f2321be2 100644 --- a/src/openforms/js/components/admin/form_design/data/complete-form.js +++ b/src/openforms/js/components/admin/form_design/data/complete-form.js @@ -91,7 +91,6 @@ const handleAppointmentForm = draft => { draft.stepsToDelete = draft.formSteps.map(step => step.url).filter(Boolean); draft.formSteps = []; draft.logicRules = []; - draft.priceRules = []; draft.formVariables = []; }; @@ -221,7 +220,7 @@ const saveSteps = async (state, csrftoken) => { }; /** - * Save the logic rules and price rules, report back any validation errors. + * Save the logic rules, report back any validation errors. */ const saveLogic = async (state, csrftoken) => { const { @@ -239,23 +238,14 @@ const saveLogic = async (state, csrftoken) => { for (const action of rule.actions) { action.formStep = getStepReference(stepsByGeneratedId, action.formStep); } - - for (const rule of draft.priceRules) { - rule.form = formUrl; - } } }); // make the actual API call let errors = []; - let responseLogicRules, responsePriceRules; + let responseLogicRules; try { - responseLogicRules = await createOrUpdateLogicRules( - formUrl, - newState.logicRules, - csrftoken, - false - ); + responseLogicRules = await createOrUpdateLogicRules(formUrl, newState.logicRules, csrftoken); newState = produce(newState, draft => { draft.logicRules = responseLogicRules.data; @@ -274,28 +264,6 @@ const saveLogic = async (state, csrftoken) => { } } - try { - responsePriceRules = await createOrUpdateLogicRules( - formUrl, - newState.priceRules, - csrftoken, - true - ); - - newState = produce(newState, draft => { - draft.priceRules = responsePriceRules.data; - }); - } catch (e) { - if (e instanceof ValidationErrors) { - e.context = 'priceRules'; - // TODO: convert in list of errors for further processing? - errors = errors.concat([e]); - } else { - // re-throw any other type of error - throw e; - } - } - return [newState, errors]; }; @@ -435,7 +403,7 @@ const saveCompleteForm = async (state, csrftoken) => { // since the logic rules validate if the variables in the trigger exist. [newState, variableValidationErrors] = await saveVariables(newState, csrftoken); - // save (normal) logic and price logic rules + // save logic rules [newState, logicValidationErrors] = await saveLogic(newState, csrftoken); const validationErrors = [...logicValidationErrors, ...variableValidationErrors]; diff --git a/src/openforms/js/components/admin/form_design/data/logic.js b/src/openforms/js/components/admin/form_design/data/logic.js index 2b672d1a29..228bfbc8f8 100644 --- a/src/openforms/js/components/admin/form_design/data/logic.js +++ b/src/openforms/js/components/admin/form_design/data/logic.js @@ -1,7 +1,7 @@ import {put} from 'utils/fetch'; -const createOrUpdateLogicRules = async (formUrl, logicRules, csrftoken, isPriceRule = false) => { - const endpoint = isPriceRule ? `${formUrl}/price-logic-rules` : `${formUrl}/logic-rules`; +const createOrUpdateLogicRules = async (formUrl, logicRules, csrftoken) => { + const endpoint = `${formUrl}/logic-rules`; return await put(endpoint, csrftoken, logicRules, true); }; diff --git a/src/openforms/js/components/admin/form_design/data/read-form.js b/src/openforms/js/components/admin/form_design/data/read-form.js index 3a621b2434..de00d823ff 100644 --- a/src/openforms/js/components/admin/form_design/data/read-form.js +++ b/src/openforms/js/components/admin/form_design/data/read-form.js @@ -17,7 +17,6 @@ const loadForm = async formUuid => { get(`${FORM_ENDPOINT}/${formUuid}/steps`), get(`${FORM_ENDPOINT}/${formUuid}/variables?source=${VARIABLE_SOURCES.userDefined}`), get(`${FORM_ENDPOINT}/${formUuid}/logic-rules`), - get(`${FORM_ENDPOINT}/${formUuid}/price-logic-rules`), ]; const responses = await Promise.all(requests); @@ -25,13 +24,7 @@ const loadForm = async formUuid => { throw new Error('An error occurred while loading the form data.'); } - const [ - formResponse, - formStepsResponse, - formVariablesResponse, - logicRulesResponse, - priceRulesResponse, - ] = responses; + const [formResponse, formStepsResponse, formVariablesResponse, logicRulesResponse] = responses; const form = formResponse.data; @@ -41,7 +34,6 @@ const loadForm = async formUuid => { steps: formStepsResponse.data, variables: formVariablesResponse.data, logicRules: logicRulesResponse.data, - priceRules: priceRulesResponse.data, }; }; diff --git a/src/openforms/js/components/admin/form_design/form-creation-form.js b/src/openforms/js/components/admin/form_design/form-creation-form.js index acb9ea330f..3fd8752430 100644 --- a/src/openforms/js/components/admin/form_design/form-creation-form.js +++ b/src/openforms/js/components/admin/form_design/form-creation-form.js @@ -33,7 +33,7 @@ import FormSteps from './FormSteps'; import FormSubmit from './FormSubmit'; import {DEFAULT_LANGUAGE} from './LanguageTabs'; import PaymentFields from './PaymentFields'; -import {EMPTY_PRICE_RULE, PriceLogic} from './PriceLogic'; +import PriceLogic from './PriceLogic'; import ProductFields from './ProductFields'; import RegistrationFields from './RegistrationFields'; import Tab from './Tab'; @@ -138,7 +138,6 @@ const initialFormState = { stepsToDelete: [], submitting: false, logicRules: [], - priceRules: [], formVariables: [], staticVariables: [], // backend error handling @@ -179,7 +178,6 @@ const FORM_FIELDS_TO_TAB_NAMES = { paymentBackendOptions: 'product-payment', submissionsRemovalOptions: 'submission-removal-options', logicRules: 'logic-rules', - priceRules: 'product-payment', variables: 'variables', appointmentOptions: 'form', brpPersonenRequestOptions: 'advanced-configuration', @@ -203,7 +201,7 @@ function reducer(draft, action) { */ case 'BACKEND_DATA_LOADED': { const {supportingData, formData} = action.payload; - const {form, selectedAuthPlugins, steps, variables, logicRules, priceRules} = formData; + const {form, selectedAuthPlugins, steps, variables, logicRules} = formData; for (const [stateVar, data] of Object.entries(supportingData)) { draft[stateVar] = data; @@ -219,7 +217,6 @@ function reducer(draft, action) { // if there's a description set already, it may not be mutated _mayGenerateDescription: !rule.description, })); - if (priceRules) draft.priceRules = priceRules; if (!draft.form.confirmationEmailTemplate) { draft.form.confirmationEmailTemplate = {subject: '', content: '', translations: {}}; @@ -837,46 +834,6 @@ function reducer(draft, action) { break; } - /** - * Price rules actions - */ - case 'ADD_PRICE_RULE': { - const { - form: {url}, - } = draft; - draft.priceRules.push({ - ...EMPTY_PRICE_RULE, - form: url, - _generatedId: getUniqueRandomString(), - }); - break; - } - case 'CHANGED_PRICE_RULE': { - const {index, name, value} = action.payload; - draft.priceRules[index][name] = value; - - const [validationErrors, tabsWithErrors] = updateWarningsValidationError( - draft.validationErrors, - draft.tabsWithErrors, - 'priceRules', - index, - name, - FORM_FIELDS_TO_TAB_NAMES['priceRules'] - ); - draft.validationErrors = validationErrors; - draft.tabsWithErrors = tabsWithErrors; - break; - } - case 'DELETED_PRICE_RULE': { - const {index} = action.payload; - - // delete object from state - const updatedRules = [...draft.priceRules]; - updatedRules.splice(index, 1); - draft.priceRules = updatedRules; - break; - } - /** * Submit & validation error handling */ @@ -1171,14 +1128,6 @@ const FormCreationForm = ({formUuid, formUrl, formHistoryUrl, outgoingRequestsUr }); }; - const onPriceRuleChange = (index, event) => { - const {name, value} = event.target; - dispatch({ - type: 'CHANGED_PRICE_RULE', - payload: {name, value, index}, - }); - }; - const onSubmit = async event => { const {name: submitAction} = event.target; const isCreate = state.newForm; @@ -1495,14 +1444,7 @@ const FormCreationForm = ({formUuid, formUrl, formHistoryUrl, outgoingRequestsUr backendOptions={state.form.paymentBackendOptions} onChange={onFieldChange} /> - dispatch({type: 'DELETED_PRICE_RULE', payload: {index: index}})} - onAdd={() => dispatch({type: 'ADD_PRICE_RULE'})} - onFieldChange={onFieldChange} - /> + )} diff --git a/src/openforms/js/lang/en.json b/src/openforms/js/lang/en.json index 6e4c1ae471..0c535db7ce 100644 --- a/src/openforms/js/lang/en.json +++ b/src/openforms/js/lang/en.json @@ -614,11 +614,6 @@ "description": "\"+\" operator description", "originalDefault": "plus" }, - "BrsG4w": { - "defaultMessage": "Then the price is", - "description": "Price logic prefix", - "originalDefault": "Then the price is" - }, "C+91tl": { "defaultMessage": "Data extraction", "description": "Service fetch configuration modal data extraction fieldset title", @@ -1449,11 +1444,6 @@ "description": "Output mapping title", "originalDefault": "Output mapping" }, - "QAbqb6": { - "defaultMessage": "Add rule", - "description": "Add price logic rule button", - "originalDefault": "Add rule" - }, "QDBI2H": { "defaultMessage": "Select existing form definition", "description": "Select form definition tile", @@ -2094,11 +2084,6 @@ "description": "Email registration: attachments fieldset title", "originalDefault": "Attachments" }, - "eJEu3L": { - "defaultMessage": "Are you sure you want to delete this rule?", - "description": "Price rule deletion confirm message", - "originalDefault": "Are you sure you want to delete this rule?" - }, "eLDxD5": { "defaultMessage": "Advanced options", "description": "Logic rule advanced options icon title", @@ -3129,11 +3114,6 @@ "description": "Ogone legacy options modal title", "originalDefault": "Plugin configuration: Ogone legacy" }, - "zeXZzX": { - "defaultMessage": "Use logic rules to determine the price", - "description": "dynamic pricing mode label", - "originalDefault": "Use logic rules to determine the price" - }, "zwOwrD": { "defaultMessage": "Select a property from the object type", "description": "Prefill / Objects API: accessible label for object type property selection", diff --git a/src/openforms/js/lang/nl.json b/src/openforms/js/lang/nl.json index 9864bf1f5f..d078487879 100644 --- a/src/openforms/js/lang/nl.json +++ b/src/openforms/js/lang/nl.json @@ -620,11 +620,6 @@ "isTranslated": true, "originalDefault": "plus" }, - "BrsG4w": { - "defaultMessage": "dan is de prijs", - "description": "Price logic prefix", - "originalDefault": "Then the price is" - }, "C+91tl": { "defaultMessage": "Gegevensverwerking", "description": "Service fetch configuration modal data extraction fieldset title", @@ -1464,11 +1459,6 @@ "description": "Output mapping title", "originalDefault": "Output mapping" }, - "QAbqb6": { - "defaultMessage": "Regel toevoegen", - "description": "Add price logic rule button", - "originalDefault": "Add rule" - }, "QDBI2H": { "defaultMessage": "Selecteer een bestaande formulierdefinitie", "description": "Select form definition tile", @@ -2114,11 +2104,6 @@ "description": "Email registration: attachments fieldset title", "originalDefault": "Attachments" }, - "eJEu3L": { - "defaultMessage": "Weet u zeker dat u deze regel wil verwijderen?", - "description": "Price rule deletion confirm message", - "originalDefault": "Are you sure you want to delete this rule?" - }, "eLDxD5": { "defaultMessage": "Geavanceerde opties", "description": "Logic rule advanced options icon title", @@ -3151,11 +3136,6 @@ "description": "Ogone legacy options modal title", "originalDefault": "Plugin configuration: Ogone legacy" }, - "zeXZzX": { - "defaultMessage": "Gebruik prijsregels om de prijs te bepalen", - "description": "dynamic pricing mode label", - "originalDefault": "Use logic rules to determine the price" - }, "zwOwrD": { "defaultMessage": "Selecteer een attribuut uit het objecttype", "description": "Prefill / Objects API: accessible label for object type property selection", diff --git a/src/openforms/submissions/api/mixins.py b/src/openforms/submissions/api/mixins.py index a51691cbc7..310a14a68e 100644 --- a/src/openforms/submissions/api/mixins.py +++ b/src/openforms/submissions/api/mixins.py @@ -34,7 +34,7 @@ def _complete_submission(self, submission: Submission) -> str: submission.calculate_price(save=False) submission.completed_on = timezone.now() - persist_user_defined_variables(submission, self.request) + persist_user_defined_variables(submission) # all logic has run; we can fix backend submission.save() diff --git a/src/openforms/submissions/api/viewsets.py b/src/openforms/submissions/api/viewsets.py index ebe974b574..852c96a933 100644 --- a/src/openforms/submissions/api/viewsets.py +++ b/src/openforms/submissions/api/viewsets.py @@ -122,12 +122,8 @@ class SubmissionViewSet( mixins.CreateModelMixin, viewsets.ReadOnlyModelViewSet, ): - queryset = ( - Submission.objects.select_related("form", "form__product") - .prefetch_related( - "form__formpricelogic_set", - ) - .order_by("created_on") + queryset = Submission.objects.select_related("form", "form__product").order_by( + "created_on" ) serializer_class = SubmissionSerializer authentication_classes = (AnonCSRFSessionAuthentication,) diff --git a/src/openforms/submissions/migrations/0012_alter_submission_price.py b/src/openforms/submissions/migrations/0012_alter_submission_price.py new file mode 100644 index 0000000000..cc7276b2b3 --- /dev/null +++ b/src/openforms/submissions/migrations/0012_alter_submission_price.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.16 on 2024-11-26 10:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0011_remove_submissionstep__data"), + ] + + operations = [ + migrations.AlterField( + model_name="submission", + name="price", + field=models.DecimalField( + blank=True, + decimal_places=2, + editable=False, + help_text="Cost of this submission. Either derived from the related product, or set through logic rules. The price is calculated and saved on submission completion.", + max_digits=10, + null=True, + verbose_name="price", + ), + ), + ] diff --git a/src/openforms/submissions/models/submission.py b/src/openforms/submissions/models/submission.py index f41ec6716f..8bb9f7a642 100644 --- a/src/openforms/submissions/models/submission.py +++ b/src/openforms/submissions/models/submission.py @@ -142,7 +142,7 @@ class Submission(models.Model): editable=False, help_text=_( "Cost of this submission. Either derived from the related product, " - "or evaluated from price logic rules. The price is calculated and saved " + "or set through logic rules. The price is calculated and saved " "on submission completion." ), ) diff --git a/src/openforms/submissions/pricing.py b/src/openforms/submissions/pricing.py index 173c6a25ab..7099c69016 100644 --- a/src/openforms/submissions/pricing.py +++ b/src/openforms/submissions/pricing.py @@ -2,6 +2,7 @@ import decimal import logging +import warnings from decimal import Decimal from typing import TYPE_CHECKING @@ -76,6 +77,12 @@ def get_submission_price(submission: Submission) -> Decimal: data = submission.data # test the rules one by one, if relevant price_rules = form.formpricelogic_set.all() + if price_rules: + warnings.warn( + "Price logic rules are no longer supported. The left-over implementation " + "only exists for migration testing purposes.", + RuntimeWarning, + ) for rule in price_rules: # logic does not match, no point in bothering if not jsonLogic(rule.json_logic_trigger, data): diff --git a/src/openforms/submissions/tests/test_price_calculation.py b/src/openforms/submissions/tests/test_price_calculation.py index 93f39358a9..ffb074bbed 100644 --- a/src/openforms/submissions/tests/test_price_calculation.py +++ b/src/openforms/submissions/tests/test_price_calculation.py @@ -2,11 +2,11 @@ from django.test import TestCase -from openforms.forms.tests.factories import FormPriceLogicFactory, FormVariableFactory +from openforms.forms.tests.factories import FormVariableFactory from openforms.variables.constants import FormVariableDataTypes from ..pricing import InvalidPrice, get_submission_price -from .factories import SubmissionFactory, SubmissionStepFactory +from .factories import SubmissionFactory class PriceCalculationTests(TestCase): @@ -26,35 +26,6 @@ def test_price_from_related_product(self): self.assertEqual(price, Decimal("123.45")) - def test_price_from_logic_rules(self): - submission = SubmissionFactory.create( - form__generate_minimal_setup=True, - form__product__price=Decimal("123.45"), - form__payment_backend="demo", - form__price_variable_key="", - ) - FormVariableFactory.create( - key="test-key", - form=submission.form, - form_definition=submission.form.formstep_set.get().form_definition, - ) - SubmissionStepFactory.create( - submission=submission, - form_step=submission.form.formstep_set.get(), - data={"test-key": "test"}, - ) - FormPriceLogicFactory.create( - form=submission.form, - json_logic_trigger={"==": [{"var": "test-key"}, "test"]}, - price=Decimal("51.15"), - ) - with self.subTest(part="check data setup"): - self.assertTrue(submission.payment_required) - - price = get_submission_price(submission) - - self.assertEqual(price, Decimal("51.15")) - def test_price_from_form_variable(self): submission = SubmissionFactory.create( completed=True, diff --git a/src/openforms/submissions/tests/test_read_submission.py b/src/openforms/submissions/tests/test_read_submission.py index fe767c4d18..7f95237201 100644 --- a/src/openforms/submissions/tests/test_read_submission.py +++ b/src/openforms/submissions/tests/test_read_submission.py @@ -18,13 +18,13 @@ from openforms.forms.tests.factories import ( FormFactory, FormLogicFactory, - FormPriceLogicFactory, FormStepFactory, FormVariableFactory, ) from openforms.logging.models import TimelineLogProxy from openforms.variables.constants import FormVariableDataTypes, FormVariableSources +from ..utils import persist_user_defined_variables from .factories import ( SubmissionFactory, SubmissionStepFactory, @@ -193,6 +193,9 @@ def test_submission_payment_information_uses_logic_rules(self): form__generate_minimal_setup=True, form__product__price=Decimal("123.45"), form__payment_backend="demo", + form__price_logic__price_variable="totalPrice", + form__price_logic__price_value=51.15, + form__price_logic__json_logic_trigger={"==": [{"var": "test-key"}, "test"]}, ) FormVariableFactory.create( key="test-key", @@ -204,11 +207,7 @@ def test_submission_payment_information_uses_logic_rules(self): form_step=submission.form.formstep_set.get(), data={"test-key": "test"}, ) - FormPriceLogicFactory.create( - form=submission.form, - json_logic_trigger={"==": [{"var": "test-key"}, "test"]}, - price=Decimal("51.15"), - ) + persist_user_defined_variables(submission) submission.calculate_price() with self.subTest(part="check data setup"): self.assertTrue(submission.payment_required) @@ -270,6 +269,11 @@ def test_submission_payment_with_logic_using_user_defined_variables(self): submitted_data={"triggerComponent": 1}, form__product__price=Decimal("10"), form__payment_backend="demo", + form__price_logic__price_variable="totalPrice", + form__price_logic__json_logic_trigger={ + "==": [{"var": "userDefinedVar"}, 2] + }, + form__price_logic__price_value=20, ) SubmissionValueVariableFactory.create( key="userDefinedVar", @@ -288,15 +292,10 @@ def test_submission_payment_with_logic_using_user_defined_variables(self): "action": {"type": "variable", "value": 2}, } ], + order=1, ) - FormPriceLogicFactory.create( - form=submission.form, - json_logic_trigger={"==": [{"var": "userDefinedVar"}, 2]}, - price=Decimal("20"), - ) - - self.assertTrue(submission.payment_required) - + persist_user_defined_variables(submission) + assert submission.payment_required self._add_submission_to_session(submission) endpoint = reverse("api:submission-detail", kwargs={"uuid": submission.uuid}) diff --git a/src/openforms/submissions/tests/test_submission_completion.py b/src/openforms/submissions/tests/test_submission_completion.py index 4823122e9a..da68c84602 100644 --- a/src/openforms/submissions/tests/test_submission_completion.py +++ b/src/openforms/submissions/tests/test_submission_completion.py @@ -25,7 +25,6 @@ from openforms.forms.tests.factories import ( FormFactory, FormLogicFactory, - FormPriceLogicFactory, FormRegistrationBackendFactory, FormStepFactory, FormVariableFactory, @@ -34,6 +33,7 @@ from openforms.registrations.base import BasePlugin from openforms.registrations.registry import Registry from openforms.registrations.tests.utils import patch_registry +from openforms.submissions.pricing import InvalidPrice from openforms.variables.constants import FormVariableDataTypes, FormVariableSources from ..constants import SUBMISSIONS_SESSION_KEY, PostSubmissionEvents @@ -667,17 +667,15 @@ def test_no_product_linked_but_price_rules_set(self): form__generate_minimal_setup=True, form__product=None, form__payment_backend="demo", + form__price_logic__price_variable="totalPrice", + form__price_logic__price_value=9.6, + form__price_logic__json_logic_trigger={"==": [{"var": "test-key"}, "test"]}, ) SubmissionStepFactory.create( submission=submission, form_step=submission.form.formstep_set.get(), data={"test-key": "test"}, ) - FormPriceLogicFactory.create( - form=submission.form, - json_logic_trigger={"==": [{"var": "test-key"}, "test"]}, - price=Decimal("9.6"), - ) with self.subTest(part="check data setup"): self.assertFalse(submission.payment_required) self._add_submission_to_session(submission) @@ -718,6 +716,9 @@ def test_price_rules_specified(self): form__generate_minimal_setup=True, form__product__price=Decimal("123.45"), form__payment_backend="demo", + form__price_logic__price_variable="totalPrice", + form__price_logic__price_value=51.15, + form__price_logic__json_logic_trigger={"==": [{"var": "test-key"}, "test"]}, ) FormVariableFactory.create( key="test-key", @@ -729,13 +730,6 @@ def test_price_rules_specified(self): form_step=submission.form.formstep_set.get(), data={"test-key": "test"}, ) - FormPriceLogicFactory.create( - form=submission.form, - json_logic_trigger={"==": [{"var": "test-key"}, "test"]}, - price=Decimal("51.15"), - ) - with self.subTest(part="check data setup"): - self.assertTrue(submission.payment_required) self._add_submission_to_session(submission) endpoint = reverse("api:submission-complete", kwargs={"uuid": submission.uuid}) @@ -748,12 +742,18 @@ def test_price_rules_specified(self): def test_price_rules_specified_but_no_match(self): """ - Assert that the product price is used as fallback. + When there is ambiguity, we bail out instead of guessing/falling back to + product price. """ submission = SubmissionFactory.create( form__generate_minimal_setup=True, form__product__price=Decimal("123.45"), form__payment_backend="demo", + form__price_logic__price_variable="totalPrice", + form__price_logic__price_value=51.15, + form__price_logic__json_logic_trigger={ + "==": [{"var": "test-key"}, "nottest"] + }, ) FormVariableFactory.create( key="test-key", @@ -765,22 +765,11 @@ def test_price_rules_specified_but_no_match(self): form_step=submission.form.formstep_set.get(), data={"test-key": "test"}, ) - FormPriceLogicFactory.create( - form=submission.form, - json_logic_trigger={"==": [{"var": "test-key"}, "nottest"]}, - price=Decimal("51.15"), - ) - with self.subTest(part="check data setup"): - self.assertTrue(submission.payment_required) self._add_submission_to_session(submission) endpoint = reverse("api:submission-complete", kwargs={"uuid": submission.uuid}) - response = self.client.post(endpoint, {"privacy_policy_accepted": True}) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - submission.refresh_from_db() - self.assertTrue(submission.payment_required) - self.assertEqual(submission.price, Decimal("123.45")) + with self.assertRaises(InvalidPrice): + self.client.post(endpoint, {"privacy_policy_accepted": True}) @override_settings(CELERY_TASK_ALWAYS_EAGER=True) diff --git a/src/openforms/submissions/utils.py b/src/openforms/submissions/utils.py index 6d8d1e8b52..c88100587e 100644 --- a/src/openforms/submissions/utils.py +++ b/src/openforms/submissions/utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from contextlib import contextmanager from typing import Any @@ -239,9 +241,7 @@ def initialise_user_defined_variables(submission: Submission): ) -def persist_user_defined_variables( - submission: "Submission", request: "Request" -) -> None: +def persist_user_defined_variables(submission: Submission) -> None: data = submission.data last_form_step = submission.submissionstep_set.order_by("form_step__order").last()