From af7bc7e96446eb2eb8c09de9406bcd1570cb6ec1 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Thu, 10 Oct 2024 15:00:04 +0200 Subject: [PATCH] [maykinmedia/open-producten#24] Add product price logic --- src/openforms/api/urls.py | 4 + src/openforms/conf/base.py | 1 + .../contrib/open_producten/__init__.py | 1 + src/openforms/contrib/open_producten/admin.py | 21 ++++ .../contrib/open_producten/api/__init__.py | 0 .../contrib/open_producten/api/serializers.py | 27 ++++ .../contrib/open_producten/api/viewsets.py | 41 +++++++ .../contrib/open_producten/api_models.py | 28 +++++ src/openforms/contrib/open_producten/apps.py | 8 ++ .../contrib/open_producten/client.py | 40 ++++++ .../open_producten/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/import_prices.py | 34 +++++ .../open_producten/migrations/0001_initial.py | 110 +++++++++++++++++ .../open_producten/migrations/__init__.py | 0 .../contrib/open_producten/models.py | 116 ++++++++++++++++++ .../contrib/open_producten/price_import.py | 95 ++++++++++++++ src/openforms/formio/components/custom.py | 30 +++++ .../forms/api/serializers/form_step.py | 6 +- src/openforms/forms/models/form.py | 6 +- src/openforms/forms/validators.py | 30 +++++ .../js/components/form/productPrice.js | 7 +- ...d_product_open_producten_price_and_more.py | 74 +++++++++++ src/openforms/products/models/product.py | 6 +- src/openforms/submissions/pricing.py | 24 +++- 25 files changed, 700 insertions(+), 9 deletions(-) create mode 100644 src/openforms/contrib/open_producten/__init__.py create mode 100644 src/openforms/contrib/open_producten/admin.py create mode 100644 src/openforms/contrib/open_producten/api/__init__.py create mode 100644 src/openforms/contrib/open_producten/api/serializers.py create mode 100644 src/openforms/contrib/open_producten/api/viewsets.py create mode 100644 src/openforms/contrib/open_producten/api_models.py create mode 100644 src/openforms/contrib/open_producten/apps.py create mode 100644 src/openforms/contrib/open_producten/client.py create mode 100644 src/openforms/contrib/open_producten/management/__init__.py create mode 100644 src/openforms/contrib/open_producten/management/commands/__init__.py create mode 100644 src/openforms/contrib/open_producten/management/commands/import_prices.py create mode 100644 src/openforms/contrib/open_producten/migrations/0001_initial.py create mode 100644 src/openforms/contrib/open_producten/migrations/__init__.py create mode 100644 src/openforms/contrib/open_producten/models.py create mode 100644 src/openforms/contrib/open_producten/price_import.py create mode 100644 src/openforms/products/migrations/0002_product_is_deleted_product_open_producten_price_and_more.py diff --git a/src/openforms/api/urls.py b/src/openforms/api/urls.py index 332abb248a..c2eae57195 100644 --- a/src/openforms/api/urls.py +++ b/src/openforms/api/urls.py @@ -27,6 +27,7 @@ from openforms.utils.urls import decorator_include from openforms.variables.api.viewsets import ServiceFetchConfigurationViewSet +from ..contrib.open_producten.api.viewsets import PriceViewSet from .views import PingView # from .schema import schema_view @@ -58,6 +59,9 @@ # products router.register("products", ProductViewSet) +# product prices (Open Producten) +router.register("product_prices", PriceViewSet, "prices") + # services router.register("services", ServiceViewSet) diff --git a/src/openforms/conf/base.py b/src/openforms/conf/base.py index c6fbdab1e6..bd1549b7b3 100644 --- a/src/openforms/conf/base.py +++ b/src/openforms/conf/base.py @@ -211,6 +211,7 @@ "openforms.contrib.haal_centraal", "openforms.contrib.kadaster", "openforms.contrib.kvk", + "openforms.contrib.open_producten", "openforms.contrib.microsoft.apps.MicrosoftApp", "openforms.contrib.objects_api", "openforms.dmn", diff --git a/src/openforms/contrib/open_producten/__init__.py b/src/openforms/contrib/open_producten/__init__.py new file mode 100644 index 0000000000..04f2f9d3d3 --- /dev/null +++ b/src/openforms/contrib/open_producten/__init__.py @@ -0,0 +1 @@ +PRICE_OPTION_KEY = "productPrice" diff --git a/src/openforms/contrib/open_producten/admin.py b/src/openforms/contrib/open_producten/admin.py new file mode 100644 index 0000000000..6ed049ece5 --- /dev/null +++ b/src/openforms/contrib/open_producten/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from solo.admin import SingletonModelAdmin + +from .models import OpenProductenConfig + + +@admin.register(OpenProductenConfig) +class OpenProductenConfigAdmin(SingletonModelAdmin): + + fieldsets = [ + ( + _("Services"), + { + "fields": [ + "producten_service", + ], + }, + ) + ] diff --git a/src/openforms/contrib/open_producten/api/__init__.py b/src/openforms/contrib/open_producten/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/openforms/contrib/open_producten/api/serializers.py b/src/openforms/contrib/open_producten/api/serializers.py new file mode 100644 index 0000000000..2e46fabd1a --- /dev/null +++ b/src/openforms/contrib/open_producten/api/serializers.py @@ -0,0 +1,27 @@ +from rest_framework import serializers + +from openforms.products.models import Product as ProductType + +from ..models import Price, PriceOption + + +class PriceOptionSerializer(serializers.ModelSerializer): + class Meta: + model = PriceOption + exclude = ("price",) + + +class PriceSerializer(serializers.ModelSerializer): + options = PriceOptionSerializer(many=True) + + class Meta: + model = Price + fields = "__all__" + + +class ProductTypeSerializer(serializers.ModelSerializer): + open_producten_price = PriceSerializer() + + class Meta: + model = ProductType + exclude = ("id", "information", "price") diff --git a/src/openforms/contrib/open_producten/api/viewsets.py b/src/openforms/contrib/open_producten/api/viewsets.py new file mode 100644 index 0000000000..b2955ca982 --- /dev/null +++ b/src/openforms/contrib/open_producten/api/viewsets.py @@ -0,0 +1,41 @@ +from django.utils.translation import gettext_lazy as _ + +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import authentication, permissions, viewsets + +from openforms.products.models import Product as ProductType + +from .serializers import ProductTypeSerializer + + +@extend_schema_view( + list=extend_schema( + summary=_("List available products with current price options"), + ), + retrieve=extend_schema( + summary=_("Retrieve details of a single product"), + ), +) +class PriceViewSet(viewsets.ReadOnlyModelViewSet): + """ + List and retrieve the products registered in the admin interface. + + Note that these endpoints are only available to authenticated admin users. The + products functionality is minimal to be able to register prices. In the future, + probably a dedicated products catalogue will become relevant. + """ + + # queryset = ProductType.objects.all() + authentication_classes = ( + authentication.SessionAuthentication, + authentication.TokenAuthentication, + ) + permission_classes = (permissions.IsAdminUser,) + serializer_class = ProductTypeSerializer + lookup_field = "open_product_uuid" + lookup_url_kwarg = "uuid" + + def get_queryset(self): + return ProductType.objects.filter( + open_producten_price__isnull=False, is_deleted=False + ).distinct() diff --git a/src/openforms/contrib/open_producten/api_models.py b/src/openforms/contrib/open_producten/api_models.py new file mode 100644 index 0000000000..eec6e11be7 --- /dev/null +++ b/src/openforms/contrib/open_producten/api_models.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from datetime import date +from typing import Optional + +from zgw_consumers.api_models.base import Model + + +@dataclass +class PriceOption(Model): + id: str + amount: str + description: str + + +@dataclass +class Price(Model): + id: str + valid_from: date + options: list[PriceOption] + + +@dataclass +class ProductType(Model): + id: str + name: str + current_price: Optional[Price] + upl_name: str + upl_uri: str diff --git a/src/openforms/contrib/open_producten/apps.py b/src/openforms/contrib/open_producten/apps.py new file mode 100644 index 0000000000..680ca9ef43 --- /dev/null +++ b/src/openforms/contrib/open_producten/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class OpenProductenConfig(AppConfig): + name = "openforms.contrib.open_producten" + label = "open_producten" + verbose_name = _("Open Producten configuration") diff --git a/src/openforms/contrib/open_producten/client.py b/src/openforms/contrib/open_producten/client.py new file mode 100644 index 0000000000..89a0abb47f --- /dev/null +++ b/src/openforms/contrib/open_producten/client.py @@ -0,0 +1,40 @@ +import logging + +import requests +from ape_pie import APIClient +from zgw_consumers.api_models.base import factory +from zgw_consumers.client import build_client + +from openforms.contrib.open_producten.models import OpenProductenConfig + +from .api_models import ProductType + +logger = logging.getLogger(__name__) + + +class OpenProductenClient(APIClient): + def get_current_prices(self) -> list[ProductType]: + try: + response = self.get("producttypes/current-prices") + response.raise_for_status() + except requests.RequestException as exc: + logger.exception( + "exception while making KVK basisprofiel request", exc_info=exc + ) + raise exc + + data = response.json() + product_types = factory(ProductType, data) + + return product_types + + +class NoServiceConfigured(RuntimeError): + pass + + +def get_open_producten_client() -> OpenProductenClient: + config = OpenProductenConfig.get_solo() + if not (service := config.producten_service): + raise NoServiceConfigured("No open producten service configured!") + return build_client(service, client_factory=OpenProductenClient) diff --git a/src/openforms/contrib/open_producten/management/__init__.py b/src/openforms/contrib/open_producten/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/openforms/contrib/open_producten/management/commands/__init__.py b/src/openforms/contrib/open_producten/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/openforms/contrib/open_producten/management/commands/import_prices.py b/src/openforms/contrib/open_producten/management/commands/import_prices.py new file mode 100644 index 0000000000..7ab7c8bfc9 --- /dev/null +++ b/src/openforms/contrib/open_producten/management/commands/import_prices.py @@ -0,0 +1,34 @@ +from django.core.management.base import BaseCommand + +from openforms.contrib.open_producten.client import get_open_producten_client +from openforms.contrib.open_producten.models import OpenProductenConfig +from openforms.contrib.open_producten.price_import import PriceImporter + + +class Command(BaseCommand): + help = "Import product types" + + def handle(self, *args, **options): + if OpenProductenConfig.objects.count() == 0: + self.stdout.write( + "Please define the OpenProductenConfig before running this command." + ) + return + + client = get_open_producten_client() + price_importer = PriceImporter(client) + + ( + created, + updated, + deleted_count, + soft_deleted_count, + ) = price_importer.import_product_types() + + self.stdout.write(f"deleted {deleted_count} product type(s):\n") + self.stdout.write(f"soft deleted {soft_deleted_count} product type(s):\n") + self.stdout.write(f"updated {len(updated)} exising product type(s)") + self.stdout.write(f"created {len(created)} new product type(s):\n") + + for instance in created: + self.stdout.write(f"{type(instance).__name__}: {instance.uuid}") diff --git a/src/openforms/contrib/open_producten/migrations/0001_initial.py b/src/openforms/contrib/open_producten/migrations/0001_initial.py new file mode 100644 index 0000000000..a7892a5b8b --- /dev/null +++ b/src/openforms/contrib/open_producten/migrations/0001_initial.py @@ -0,0 +1,110 @@ +# Generated by Django 4.2.16 on 2024-10-10 12:59 + +import datetime +from decimal import Decimal +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("zgw_consumers", "0020_service_timeout"), + ] + + operations = [ + migrations.CreateModel( + name="Price", + fields=[ + ("uuid", models.UUIDField(primary_key=True, serialize=False)), + ( + "valid_from", + models.DateField( + help_text="The date at which this price is valid", + unique=True, + validators=[ + django.core.validators.MinValueValidator( + datetime.date.today + ) + ], + verbose_name="Start date", + ), + ), + ], + options={ + "verbose_name": "Price", + "verbose_name_plural": "Prices", + }, + ), + migrations.CreateModel( + name="PriceOption", + fields=[ + ("uuid", models.UUIDField(primary_key=True, serialize=False)), + ( + "amount", + models.DecimalField( + decimal_places=2, + help_text="The amount of the price option", + max_digits=8, + validators=[ + django.core.validators.MinValueValidator(Decimal("0.01")) + ], + verbose_name="Price", + ), + ), + ( + "description", + models.CharField( + help_text="Short description of the option", + max_length=100, + verbose_name="Description", + ), + ), + ( + "price", + models.ForeignKey( + help_text="The price this option belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="options", + to="open_producten.price", + verbose_name="Price", + ), + ), + ], + options={ + "verbose_name": "Price option", + "verbose_name_plural": "Price options", + }, + ), + migrations.CreateModel( + name="OpenProductenConfig", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "producten_service", + models.OneToOneField( + limit_choices_to={"api_type": "orc"}, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="zgw_consumers.service", + verbose_name="Producten API", + ), + ), + ], + options={ + "verbose_name": "Open Producten configuration", + }, + ), + ] diff --git a/src/openforms/contrib/open_producten/migrations/__init__.py b/src/openforms/contrib/open_producten/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/openforms/contrib/open_producten/models.py b/src/openforms/contrib/open_producten/models.py new file mode 100644 index 0000000000..f59ace4817 --- /dev/null +++ b/src/openforms/contrib/open_producten/models.py @@ -0,0 +1,116 @@ +import datetime +from decimal import Decimal + +from django.core.validators import MinValueValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from solo.models import SingletonModel +from zgw_consumers.constants import APITypes + + +class OpenProductenConfig(SingletonModel): + + producten_service = models.OneToOneField( + "zgw_consumers.Service", + verbose_name=_("Producten API"), + on_delete=models.PROTECT, + limit_choices_to={"api_type": APITypes.orc}, + related_name="+", + null=True, + ) + + class Meta: + verbose_name = _("Open Producten configuration") + + +class BaseModel(models.Model): + uuid = models.UUIDField(primary_key=True) + + class Meta: + abstract = True + + +class Price(BaseModel): + valid_from = models.DateField( + verbose_name=_("Start date"), + validators=[MinValueValidator(datetime.date.today)], + unique=True, + help_text=_("The date at which this price is valid"), + ) + + class Meta: + verbose_name = _("Price") + verbose_name_plural = _("Prices") + + def __str__(self): + return f"{self.product_type.name} {self.valid_from}" + + +class PriceOption(BaseModel): + price = models.ForeignKey( + Price, + verbose_name=_("Price"), + on_delete=models.CASCADE, + related_name="options", + help_text=_("The price this option belongs to"), + ) + amount = models.DecimalField( + verbose_name=_("Price"), + decimal_places=2, + max_digits=8, + validators=[MinValueValidator(Decimal("0.01"))], + help_text=_("The amount of the price option"), + ) + description = models.CharField( + verbose_name=_("Description"), + max_length=100, + help_text=_("Short description of the option"), + ) + + class Meta: + verbose_name = _("Price option") + verbose_name_plural = _("Price options") + + def __str__(self): + return f"{self.description} {self.amount}" + + +class ProductType(models.Model): + open_producten_price = models.OneToOneField( + Price, + verbose_name=_("Price"), + on_delete=models.CASCADE, + related_name="product_type", + help_text=_("The price this option belongs to"), + blank=True, + null=True, + editable=False, + ) + upl_name = models.CharField( + verbose_name=_("Name"), + max_length=100, + help_text=_("Uniform product name"), + blank=True, + null=True, + editable=False, + ) + + upl_uri = models.URLField( + verbose_name=_("Url"), + blank=True, + null=True, + editable=False, + help_text=_("Url to the upn definition."), + ) + + is_deleted = models.BooleanField( + verbose_name=_("Is deleted"), + default=False, + help_text=_( + "set when the product is deleted in open producten but is linked to existing submission." + ), + ) + + class Meta: + abstract = True diff --git a/src/openforms/contrib/open_producten/price_import.py b/src/openforms/contrib/open_producten/price_import.py new file mode 100644 index 0000000000..c073a88e2a --- /dev/null +++ b/src/openforms/contrib/open_producten/price_import.py @@ -0,0 +1,95 @@ +from django.db import models, transaction +from django.db.models import Q +from django.db.models.deletion import ProtectedError + +import openforms.contrib.open_producten.api_models as api_models +from openforms.contrib.open_producten.models import Price, PriceOption +from openforms.products.models import Product as ProductType + + +class PriceImporter: + + def __init__(self, client): + self.client = client + self.created_objects = [] + self.updated_objects = [] + self.deleted_count = 0 + self.soft_deleted_count = 0 + self.product_types = [] + + def _add_to_log_list(self, instance: models.Model, created: bool): + if created: + self.created_objects.append(instance) + else: + self.updated_objects.append(instance) + + @transaction.atomic() + def import_product_types(self): + + self.product_types = self.client.get_current_prices() + self._handle_product_types(self.product_types) + self._delete_non_updated_objects() + return ( + self.created_objects, + self.updated_objects, + self.deleted_count, + self.soft_deleted_count, + ) + + def _handle_options(self, options: [api_models.PriceOption], price_instance: Price): + for option in options: + option_instance, created = PriceOption.objects.update_or_create( + uuid=option.id, + defaults={ + "amount": option.amount, + "description": option.description, + "price": price_instance, + }, + ) + self._add_to_log_list(option_instance, created) + + def _update_or_create_price(self, price: api_models.Price): + price_instance, created = Price.objects.update_or_create( + uuid=price.id, + defaults={ + "valid_from": price.valid_from, + }, + ) + self._add_to_log_list(price_instance, created) + return price_instance + + def _handle_product_types(self, product_types: list[api_models.ProductType]): + for product_type in product_types: + + if product_type.current_price: + price_instance = self._update_or_create_price( + product_type.current_price + ) + self._handle_options(product_type.current_price.options, price_instance) + else: + price_instance = None + + product_type_instance, created = ProductType.objects.update_or_create( + uuid=product_type.id, + defaults={ + "name": product_type.name, + "upl_uri": product_type.upl_uri, + "upl_name": product_type.upl_name, + "open_producten_price": price_instance, + }, + ) + self._add_to_log_list(product_type_instance, created) + + def _delete_non_updated_objects(self): + objects_to_be_deleted = ProductType.objects.exclude( + Q(uuid__in=self.product_types) | Q(uuid=True) + ) + + for obj in objects_to_be_deleted: + try: + obj.delete() + self.deleted_count += 1 + except ProtectedError: + obj.is_deleted = True + obj.save() + self.soft_deleted_count += 1 diff --git a/src/openforms/formio/components/custom.py b/src/openforms/formio/components/custom.py index e7521c482b..7b8c98244b 100644 --- a/src/openforms/formio/components/custom.py +++ b/src/openforms/formio/components/custom.py @@ -348,6 +348,36 @@ def mutate_config_dynamically( ] +@register("productPrice") +class ProductPrice(BasePlugin): + # not actually relevant, as we transform the component into a different type + formatter = DefaultFormatter + + def mutate_config_dynamically( + self, component: Component, submission: Submission, data: DataMapping + ) -> None: + current_price = submission.form.product.open_producten_price + + component.update( + { + "type": "radio", + "label": "select a price option", + "fieldSet": False, + "inline": False, + "inputType": "radio", + "validate": {"required": True}, + } + ) + + component["values"] = [ + { + "label": f"{option.description}: € {option.amount}", + "value": option.uuid, + } + for option in current_price.options.all() + ] + + @register("bsn") class BSN(BasePlugin[Component]): formatter = TextFieldFormatter diff --git a/src/openforms/forms/api/serializers/form_step.py b/src/openforms/forms/api/serializers/form_step.py index 8fbf625c49..b625828e17 100644 --- a/src/openforms/forms/api/serializers/form_step.py +++ b/src/openforms/forms/api/serializers/form_step.py @@ -10,7 +10,7 @@ ) from ...models import FormDefinition, FormStep -from ...validators import validate_no_duplicate_keys_across_steps +from ...validators import validate_no_duplicate_keys_across_steps, validate_price_option from ..validators import FormStepIsApplicableIfFirstValidator from .button_text import ButtonTextSerializer from .form_definition import FormDefinitionConfigurationSerializer @@ -169,4 +169,8 @@ def validate_form_definition(self, current_form_definition): current_form_definition, list(other_form_definitions) ) + validate_price_option( + form.product, current_form_definition, list(other_form_definitions) + ) + return current_form_definition diff --git a/src/openforms/forms/models/form.py b/src/openforms/forms/models/form.py index b45ab28fdc..114fc11bde 100644 --- a/src/openforms/forms/models/form.py +++ b/src/openforms/forms/models/form.py @@ -484,7 +484,11 @@ def login_required(self) -> bool: @property def payment_required(self) -> bool: # this will later be more dynamic and determined from oa. the linked Product - return bool(self.payment_backend and self.product and self.product.price) + return bool( + self.payment_backend + and self.product + and (self.product.price or self.product.open_producten_price) + ) @transaction.atomic def copy(self): diff --git a/src/openforms/forms/validators.py b/src/openforms/forms/validators.py index 2d5bb056e9..bf4bba7e32 100644 --- a/src/openforms/forms/validators.py +++ b/src/openforms/forms/validators.py @@ -172,3 +172,33 @@ def validate_no_duplicate_keys_across_steps( errors=get_text_list(errors, ", ") ) ) + + +def validate_price_option( + form_product, current_form_definition, other_form_definitions +): + form_definitions = [current_form_definition] + other_form_definitions + + price_components = [] + for form_definition in form_definitions: + for component in form_definition.configuration["components"]: + if component["type"] == "productPrice": + price_components.append(component) + + if len(price_components) > 1: + raise ValidationError( + _( + "Currently only a single product price component is allowed be added to a form." + ) + ) + + if price_components and not hasattr(form_product, "open_producten_price"): + raise ValidationError( + _( + "Product selected for productPrice component does not have a price from Open Producten" + ) + ) + if not price_components and hasattr(form_product, "open_producten_price"): + raise ValidationError( + _("Form has product with price options but not a productPrice component") + ) diff --git a/src/openforms/js/components/form/productPrice.js b/src/openforms/js/components/form/productPrice.js index 247d47f909..d38c762462 100644 --- a/src/openforms/js/components/form/productPrice.js +++ b/src/openforms/js/components/form/productPrice.js @@ -15,16 +15,15 @@ export const getProducts = async () => { })); }; -const Select = Formio.Components.components.select; +const Radio = Formio.Components.components.radio; -class ProductPrice extends Select { +class ProductPrice extends Radio { static schema(...extend) { - const schema = Select.schema( + const schema = Radio.schema( { label: 'Select a product', key: 'productPrice', type: 'productPrice', - product: '' }, ...extend ); diff --git a/src/openforms/products/migrations/0002_product_is_deleted_product_open_producten_price_and_more.py b/src/openforms/products/migrations/0002_product_is_deleted_product_open_producten_price_and_more.py new file mode 100644 index 0000000000..33d53f1430 --- /dev/null +++ b/src/openforms/products/migrations/0002_product_is_deleted_product_open_producten_price_and_more.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2.16 on 2024-10-10 12:59 + +from decimal import Decimal +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("open_producten", "0001_initial"), + ("products", "0001_initial_to_v270"), + ] + + operations = [ + migrations.AddField( + model_name="product", + name="is_deleted", + field=models.BooleanField( + default=False, + help_text="set when the product is deleted in open producten but is linked to existing submission.", + verbose_name="Is deleted", + ), + ), + migrations.AddField( + model_name="product", + name="open_producten_price", + field=models.OneToOneField( + blank=True, + editable=False, + help_text="The price this option belongs to", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="product_type", + to="open_producten.price", + verbose_name="Price", + ), + ), + migrations.AddField( + model_name="product", + name="upl_name", + field=models.CharField( + blank=True, + editable=False, + help_text="Uniform product name", + max_length=100, + null=True, + verbose_name="Name", + ), + ), + migrations.AddField( + model_name="product", + name="upl_uri", + field=models.URLField( + blank=True, + editable=False, + help_text="Url to the upn definition.", + null=True, + verbose_name="Url", + ), + ), + migrations.AlterField( + model_name="product", + name="price", + field=models.DecimalField( + decimal_places=2, + max_digits=10, + null=True, + validators=[django.core.validators.MinValueValidator(Decimal("0.01"))], + verbose_name="price", + ), + ), + ] diff --git a/src/openforms/products/models/product.py b/src/openforms/products/models/product.py index 8e1fc56654..33d4bad141 100644 --- a/src/openforms/products/models/product.py +++ b/src/openforms/products/models/product.py @@ -8,9 +8,12 @@ from tinymce.models import HTMLField from csp_post_processor.fields import CSPPostProcessedWYSIWYGField +from openforms.contrib.open_producten.models import ( + ProductType as OpenProductenProductType, +) -class Product(models.Model): +class Product(OpenProductenProductType): """ Product model for a PDC (Producten en Diensten Catalogus) definition. """ @@ -27,6 +30,7 @@ class Product(models.Model): max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal("0.01"))], + null=True, ) information = CSPPostProcessedWYSIWYGField( diff --git a/src/openforms/submissions/pricing.py b/src/openforms/submissions/pricing.py index 173c6a25ab..d3f108aac0 100644 --- a/src/openforms/submissions/pricing.py +++ b/src/openforms/submissions/pricing.py @@ -10,6 +10,8 @@ from openforms.logging import logevent from openforms.typing import JSONValue +from openforms.contrib.open_producten import PRICE_OPTION_KEY + if TYPE_CHECKING: from .models import Submission @@ -44,7 +46,7 @@ def get_submission_price(submission: Submission) -> Decimal: ), "Price cannot be calculated on a submission without the form relation set" assert submission.form.product, "Form must have a related product" assert ( - submission.form.product.price + submission.form.product.price or submission.form.product.open_producten_price ), "get_submission_price' may only be called for forms that require payment" form = submission.form @@ -92,7 +94,25 @@ def get_submission_price(submission: Submission) -> Decimal: return rule.price # - # 3. More specific modes didn't produce anything, fall back to the linked product + # 3. Check if product has price imported from open producten. + # + if form.product.open_producten_price: + # method is called before form is completed at openforms.submissions.models.submission.Submission.payment_required + if not data.get(PRICE_OPTION_KEY): + return Decimal("0") + + # should keep current price if already set. + if submission.price: + return submission.price + + logger.debug("Price for submission set by product price option") + return form.product.open_producten_price.options.get( + uuid=data[PRICE_OPTION_KEY] + ).amount + # return data.get(PRICE_OPTION_KEY).split(':')[0].strip() + + # + # 4. More specific modes didn't produce anything, fall back to the linked product # price. # logger.debug(