diff --git a/docs/manual/forms/export_import.rst b/docs/manual/forms/export_import.rst
index 9c095f56ca..b235171c8f 100644
--- a/docs/manual/forms/export_import.rst
+++ b/docs/manual/forms/export_import.rst
@@ -62,23 +62,33 @@ Als het goed is, is het formulier inclusief alle stappen geïmporteerd. Het
geïmporteerde formulier is standaard niet actief en dus niet direct voor de
buitenwereld toegankelijk.
-Bijzonderheden
---------------
+Formulier importeer opties
+--------------------------
+
+Bij het importeren van een formulier heb je enkele opties over hoe het
+ZIP-bestand wordt geïmporteerd en hoe het formulier wordt gemaakt:
-Als een formulier wordt geïmporteerd en het bevat een stap waarvan het
-*URL-deel* overeenkomt met een bestaande stap, dan controleert het import proces
-of de te importeren stap **exact hetzelfde** is als de bestaande stap. Als dat
-zo is, dan wordt de bestaande stap gebruikt voor het geïmporteerde formulier. Er
-wordt dan geen nieuwe stap aangemaakt.
+* **Formulierinstellingen**: Hiermee kunnen registratie backends,
+ betaalprovider, prefill en inlogmethode eenvoudig bij het importeren
+ weggelaten worden. Standaard worden alle instellingen mee geïmporteerd.
+* **Aanvullende formulierinstellingen**: Hiermee kunnen aanvullende
+ instellingen uit het ZIP-bestand toegevoegd worden aan het formulier. Bij het
+ importeren wordt gekeken of de aanvullende objecten, zoals producten, al in
+ de Open Formulieren-omgeving bestaan. Zo ja, worden deze bestaande objecten
+ hergebruikt, zo niet worden ze aangemaakt.
+* **Hergebruik formulierdefinities**: Hiermee kan aangegeven worden of al
+ bestaande herbruikbare formulierdefinities gebruikt moeten worden, of dat
+ elke formulierdefinitie opnieuw aangemaakt moet worden.
+* **Stijl**: Welk stijl gebruik zal worden voor het formulier.
+* **Categorie**: In welke categorie het formulier geplaatst zal worden.
-Als de stap **niet exact hetzelfde** is, dan wordt er een nieuwe stap aangemaakt
-en wordt het *URL-deel* van de stap gewijzigd. Bijvoorbeeld van
-``persoonsgegevens`` naar ``persoonsgegevens-2``. U krijgt hier altijd een
-melding van.
+Bijzonderheden
+--------------
-Als het *URL-deel* van een te importeren formulier zelf al bestaat, dan kan het
-formulier niet worden geïmporteerd. U kunt dan het *URL-deel* van het
-conflicterende formulier zelf aanpassen en het nogmaals proberen.
+Als het *URL-deel* van een te importeren formulier zelf al bestaat, dan wordt
+het *URL-deel* van het te importeren formulier uniek gemaakt door een reeks van
+letters en cijfers er achter te plaatsen. Na het importeren kunt u het
+*URL-deel* nakijken en eventueel aanpassen.
Export en import van :ref:`logica ` regels die gebruik maken van
het experimentele bevragen van registraties is niet geïmplementeerd. De
diff --git a/docs/manual/workflows.rst b/docs/manual/workflows.rst
index 40f100387f..d89a6ff010 100644
--- a/docs/manual/workflows.rst
+++ b/docs/manual/workflows.rst
@@ -11,11 +11,11 @@ u kunt gebruiken om de levensloop van een formulier te optimaliseren.
Essentieel bij het beheer zijn 2 opties:
* **Actief** geeft de globale beschikbaarheid aan.
-* **Onderhoudsmodus** geeft aan dat een formulier tijdelijk niet beschikbaar
+* **Onderhoudsmodus** geeft aan dat een formulier tijdelijk niet beschikbaar
is.
Zodra een formulier *niet actief* of in *onderhoudsmodus* staat, dan is het
-formulier voor reguliere gebruikers direct niet meer te gebruiken en lopende
+formulier voor reguliere gebruikers direct niet meer te gebruiken en lopende
sessies kunnen niet worden afgemaakt. In het geval een formulier *niet actief*
is, dan geldt dit ook voor beheerders. In *onderhoudsmodus* kunnen beheerders
nog wel het formulier starten, invullen en verzenden.
@@ -24,15 +24,15 @@ nog wel het formulier starten, invullen en verzenden.
Nieuw formulier maken
=====================
-Als u het formulier direct op de productieomgeving bouwt, wilt u niet dat
+Als u het formulier direct op de productieomgeving bouwt, wilt u niet dat
reguliere gebruikers dit formulier al kunnen benaderen of invullen.
-Zet de optie **Onderhoudsmodus** aan voordat u de eerste maal op **Opslaan**
-klikt. Het formulier is hierdoor enkel door te starten door beheerders.
+Zet de optie **Onderhoudsmodus** aan voordat u de eerste maal op **Opslaan**
+klikt. Het formulier kan hierdoor enkel door beheerders gestart worden.
Doorloop altijd eerst zelf het formulier voordat u een formulier actief maakt.
.. note::
-
+
Als u een nieuw formulier importeert staat de optie **Actief** automatisch
uit, en het geïmporteerde formulier kan dus niet benaderd worden door
reguliere gebruikers en beheerders.
@@ -42,18 +42,18 @@ Bestaand formulier bijwerken in de beheerinterface
==================================================
Als een formulier in gebruik is op de productieomgeving, wilt u het formulier
-eventueel bijwerken. Gebruikers die op dat moment bezig zijn wilt u zo min
+eventueel bijwerken. Gebruikers die op dat moment bezig zijn wilt u zo min
mogelijk hinderen.
-Zet de optie **Onderhoudsmodus** aan en klik op **Opslaan**. Wijzig vervolgens
-het formulier waar nodig. U kunt de wijzigingen in het formulier als beheerder
-nog wel bekijken, maar reguliere gebruikers kunnen het formulier niet
+Zet de optie **Onderhoudsmodus** aan en klik op **Opslaan**. Wijzig vervolgens
+het formulier waar nodig. U kunt de wijzigingen in het formulier als beheerder
+nog wel bekijken, maar reguliere gebruikers kunnen het formulier niet
gebruiken.
.. note::
- Als de wijzigingen niet de structuur van het formulier wijzigen, zoals een
- veld of stap toevoegen dan wel verwijderen, dan kunt u het formulier gewoon
+ Als de wijzigingen niet de structuur van het formulier wijzigen, zoals een
+ veld of stap toevoegen dan wel verwijderen, dan kunt u het formulier gewoon
wijzigen en opslaan, zonder dit formulier eerst op inactief te zetten.
Lopende sessies blijven gewoon bestaan en ondervinden geen hinder.
@@ -62,7 +62,7 @@ gebruiken.
Bestaande formulieren bijwerken middels import
==============================================
-Als u een formulier heeft gemaakt op een andere omgeving (bijvoorbeeld een
+Als u een formulier heeft gemaakt op een andere omgeving (bijvoorbeeld een
test- of acceptatieomgeving) en deze wilt importeren om een bestaand formulier
te vervangen, dan is dat niet direct mogelijk.
@@ -74,24 +74,24 @@ U moet rekening houden met de volgende complicaties:
* De URL van een formulier moet uniek zijn. Om deze reden kunnen 2 formulieren
niet dezelfde URL hebben. Als de URL van een geïmporteerd formulier al in
gebruik is, dan krijgt het geïmporteerde formulier een iets andere URL.
-* De URL van formulierdefinities (stappen) moeten uniek zijn. Als er al een
+* De URL van formulierdefinities (stappen) moeten uniek zijn. Als er al een
formulierdefinitie bestaat met dezelfde URL, dan krijgt de formulierdefinitie
van het geïmporteerde formulier een iets andere URL.
-U kunt de volgende acties uitvoeren om toch een bestaand formulier te
+U kunt de volgende acties uitvoeren om toch een bestaand formulier te
vervangen middels een import:
#. Open het bestaande formulier in de beheerinterface.
-#. Zet de optie **Actief** uit en wijzig de naam zodat er *(oud)* achter komt
+#. Zet de optie **Actief** uit en wijzig de naam zodat er *(oud)* achter komt
te staan.
#. Wijzig ook de URL zodat er *-oud* achter komt te staan.
-#. Wijzig bij alle formulierstappen, de URL zodat ook hier overal *-oud* achter
- komt te staan. Heeft u **Herbruikbare** formulierstappen? Wijzig hier de URL
+#. Wijzig bij alle formulierstappen, de URL zodat ook hier overal *-oud* achter
+ komt te staan. Heeft u **Herbruikbare** formulierstappen? Wijzig hier de URL
dan niet om wijzigingen aan andere formulieren te voorkomen.
#. Sla het formulier op. Het formulier is nu niet meer actief.
-#. Importeer het het vervangende formulier.
+#. Importeer het vervangende formulier.
#. Pas eventueel de URLs aan van de formulierstappen die herbruikbaar zijn om
mooiere URLs te gebruiken.
-#. Configureer het formulier verder met alle informatie die niet is meegekomen
+#. Configureer het formulier verder met alle informatie die niet is meegekomen
met het importeren.
#. Zet het geïmporteerde formulier op **Actief** en sla het formulier op.
diff --git a/src/openapi.yaml b/src/openapi.yaml
index 29c03b633b..576ba2b72a 100644
--- a/src/openapi.yaml
+++ b/src/openapi.yaml
@@ -1476,7 +1476,10 @@ paths:
- forms
requestBody:
content:
- '*/*':
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/FormImport'
+ application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/FormImport'
required: true
@@ -6860,6 +6863,18 @@ components:
Note that every subsequent API call resets the expiry.
required: true
schemas:
+ AdditionalFormConfigurationEnum:
+ enum:
+ - product
+ - wmsTileLayers
+ - wmtsTileLayers
+ - yiviAttributeGroups
+ type: string
+ description: |-
+ * `product` - Product
+ * `wmsTileLayers` - WMS-tile layers
+ * `wmtsTileLayers` - Background tile layers
+ * `yiviAttributeGroups` - Yivi attribute groups
AddressSearchResult:
type: object
properties:
@@ -8078,6 +8093,18 @@ components:
title: authentication backend options
required:
- backend
+ FormConfigurationEnum:
+ enum:
+ - registrationBackends
+ - prefill
+ - paymentBackend
+ - authBackends
+ type: string
+ description: |-
+ * `registrationBackends` - Registration backends
+ * `prefill` - Prefill
+ * `paymentBackend` - Payment backend
+ * `authBackends` - Authentication backends
FormData:
type: object
properties:
@@ -8404,6 +8431,32 @@ components:
format: uri
description: The file that contains the form, form definitions and form
steps.
+ formConfiguration:
+ type: array
+ items:
+ $ref: '#/components/schemas/FormConfigurationEnum'
+ description: Which form configuration should be included in the export file
+ content.
+ reuseFormDefinitions:
+ type: boolean
+ title: Re-use form definitions
+ description: Whether to re-use existing form definitions or create new form
+ definitions for each form definition in the import file. (If no matching
+ reusable form definition is found, a new one will be created.)
+ additionalFormConfiguration:
+ type: array
+ items:
+ $ref: '#/components/schemas/AdditionalFormConfigurationEnum'
+ description: Which additional form configuration should be included in the
+ export file content.
+ theme:
+ type: string
+ format: uuid
+ description: Which theme should be used for the imported forms.
+ category:
+ type: string
+ format: uuid
+ description: Which category should be applied to the imported forms.
required:
- file
FormImportResponse:
diff --git a/src/openforms/conf/locale/nl/LC_MESSAGES/django.po b/src/openforms/conf/locale/nl/LC_MESSAGES/django.po
index 4e25ddc640..064e238919 100644
--- a/src/openforms/conf/locale/nl/LC_MESSAGES/django.po
+++ b/src/openforms/conf/locale/nl/LC_MESSAGES/django.po
@@ -4,8 +4,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Open Forms\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-08-02 13:12+0200\n"
-"PO-Revision-Date: 2026-08-02 13:17+0200\n"
+"POT-Creation-Date: 2026-08-02 13:29+0200\n"
+"PO-Revision-Date: 2026-08-02 13:34+0200\n"
"Last-Translator: \n"
"Language-Team: Dutch \n"
"Language: nl\n"
@@ -87,7 +87,7 @@ msgstr "stafstatus"
msgid "Designates whether the user can log into this admin site."
msgstr "Bepaalt of de gebruiker zich op deze beheerwebsite kan aanmelden."
-#: openforms/accounts/models.py:55 openforms/forms/models/form.py:312
+#: openforms/accounts/models.py:55 openforms/forms/models/form.py:318
msgid "active"
msgstr "actief"
@@ -115,7 +115,7 @@ msgstr ""
"De voorkeurstaal voor de (beheer) gebruikersinterface. Wanneer geen waarde "
"ingesteld is, worden de browserinstellingen gebruikt."
-#: openforms/accounts/models.py:85 openforms/forms/models/form.py:854
+#: openforms/accounts/models.py:85 openforms/forms/models/form.py:928
#: openforms/forms/models/form_version.py:86 soap/models.py:53
msgid "user"
msgstr "gebruiker"
@@ -757,9 +757,9 @@ msgstr "Unieke sleutel van het product"
#: openforms/config/models/map.py:70 openforms/config/models/theme.py:21
#: openforms/contrib/customer_interactions/models.py:14
#: openforms/contrib/objects_api/models.py:16
-#: openforms/dmn/api/serializers.py:76 openforms/forms/admin/form.py:271
+#: openforms/dmn/api/serializers.py:76 openforms/forms/admin/form.py:272
#: openforms/forms/admin/form_definition.py:69
-#: openforms/forms/models/category.py:13 openforms/forms/models/form.py:81
+#: openforms/forms/models/category.py:13 openforms/forms/models/form.py:87
#: openforms/forms/models/form_definition.py:40
#: openforms/forms/models/form_registration_backend.py:23
#: openforms/forms/models/form_variable.py:265
@@ -2100,8 +2100,8 @@ msgstr ""
#: openforms/authentication/contrib/yivi_oidc/models.py:20
#: openforms/config/models/map.py:64 openforms/config/models/theme.py:26
#: openforms/forms/api/serializers/logic/action_serializers.py:214
-#: openforms/forms/models/category.py:10 openforms/forms/models/form.py:80
-#: openforms/forms/models/form.py:841
+#: openforms/forms/models/category.py:10 openforms/forms/models/form.py:86
+#: openforms/forms/models/form.py:915
#: openforms/forms/models/form_definition.py:39
#: openforms/forms/models/form_step.py:27
#: openforms/forms/models/form_version.py:66
@@ -3008,7 +3008,7 @@ msgstr ""
msgid "Confirmation: {{ public_reference }}"
msgstr "Bevestiging: {{ public_reference }}"
-#: openforms/config/models/config.py:76 openforms/forms/models/form.py:171
+#: openforms/config/models/config.py:76 openforms/forms/models/form.py:177
msgid "submission confirmation template"
msgstr "Bevestigingspagina tekst"
@@ -3184,7 +3184,7 @@ msgid ""
msgstr ""
"Het label van de knop op de overzichtspagina om naar de vorige stap te gaan."
-#: openforms/config/models/config.py:240 openforms/forms/models/form.py:272
+#: openforms/config/models/config.py:240 openforms/forms/models/form.py:278
msgid "change text"
msgstr "Stap wijzigen-label"
@@ -3199,7 +3199,7 @@ msgid ""
msgstr ""
"Het label de link op de overzichtspagina om een bepaalde stap te wijzigen"
-#: openforms/config/models/config.py:249 openforms/forms/models/form.py:282
+#: openforms/config/models/config.py:249 openforms/forms/models/form.py:288
msgid "confirm text"
msgstr "Formulier verzenden-label"
@@ -3215,7 +3215,7 @@ msgid ""
msgstr ""
"Het label van de knop op de overzichtspagina om het formulier in te dienen"
-#: openforms/config/models/config.py:258 openforms/forms/models/form.py:252
+#: openforms/config/models/config.py:258 openforms/forms/models/form.py:258
msgid "begin text"
msgstr "Formulier starten-label"
@@ -3435,11 +3435,11 @@ msgstr ""
"tokens {year}, {public_reference} en {uid} zijn beschikbaar. Het sjabloon "
"moet de placeholder {uid} bevatten."
-#: openforms/config/models/config.py:434 openforms/forms/models/form.py:231
+#: openforms/config/models/config.py:434 openforms/forms/models/form.py:237
msgid "ask privacy consent"
msgstr "vraag toestemming om gegevens te verwerken"
-#: openforms/config/models/config.py:437 openforms/forms/models/form.py:236
+#: openforms/config/models/config.py:437 openforms/forms/models/form.py:242
msgid ""
"If enabled, the user will have to agree to the privacy policy before "
"submitting a form."
@@ -3475,11 +3475,11 @@ msgstr ""
"Ja, ik heb kennis genomen van het {% privacy_policy %} en geef uitdrukkelijk"
" toestemming voor het verwerken van de door mij opgegeven gegevens."
-#: openforms/config/models/config.py:467 openforms/forms/models/form.py:240
+#: openforms/config/models/config.py:467 openforms/forms/models/form.py:246
msgid "ask statement of truth"
msgstr "vraag waarheidsverklaring"
-#: openforms/config/models/config.py:470 openforms/forms/models/form.py:245
+#: openforms/config/models/config.py:470 openforms/forms/models/form.py:251
msgid ""
"If enabled, the user will have to agree that they filled out the form "
"truthfully before submitting it."
@@ -3508,7 +3508,7 @@ msgstr ""
"Ik verklaar dat ik deze aanvraag naar waarheid heb ingevuld en geen "
"informatie heb verzwegen."
-#: openforms/config/models/config.py:492 openforms/forms/models/form.py:336
+#: openforms/config/models/config.py:492 openforms/forms/models/form.py:342
msgid "successful submission removal limit"
msgstr "bewaartermijn voor voltooide inzendingen."
@@ -3516,7 +3516,7 @@ msgstr "bewaartermijn voor voltooide inzendingen."
msgid "Amount of days successful submissions will remain before being removed"
msgstr "Aantal dagen dat een voltooide inzending bewaard blijft."
-#: openforms/config/models/config.py:500 openforms/forms/models/form.py:346
+#: openforms/config/models/config.py:500 openforms/forms/models/form.py:352
msgid "successful submissions removal method"
msgstr "opschoonmethode voor voltooide inzendingen"
@@ -3525,7 +3525,7 @@ msgid "How successful submissions will be removed after the limit"
msgstr ""
"Geeft aan hoe voltooide inzendingen worden opgeschoond na de bewaartermijn."
-#: openforms/config/models/config.py:507 openforms/forms/models/form.py:356
+#: openforms/config/models/config.py:507 openforms/forms/models/form.py:362
msgid "incomplete submission removal limit"
msgstr "bewaartermijn voor sessies"
@@ -3533,7 +3533,7 @@ msgstr "bewaartermijn voor sessies"
msgid "Amount of days incomplete submissions will remain before being removed"
msgstr "Aantal dagen dat een sessie bewaard blijft."
-#: openforms/config/models/config.py:515 openforms/forms/models/form.py:366
+#: openforms/config/models/config.py:515 openforms/forms/models/form.py:372
msgid "incomplete submissions removal method"
msgstr "opschoonmethode voor sessies."
@@ -3541,8 +3541,8 @@ msgstr "opschoonmethode voor sessies."
msgid "How incomplete submissions will be removed after the limit"
msgstr "Geeft aan hoe sessies worden opgeschoond na de bewaartermijn."
-#: openforms/config/models/config.py:522 openforms/forms/models/form.py:376
-#: openforms/forms/models/form.py:386
+#: openforms/config/models/config.py:522 openforms/forms/models/form.py:382
+#: openforms/forms/models/form.py:392
msgid "errored submission removal limit"
msgstr "bewaartermijn voor niet voltooide inzendingen"
@@ -3562,7 +3562,7 @@ msgstr ""
"Geeft aan hoe niet voltooide inzendingen (door fouten in de afhandeling) "
"worden opgeschoond na de bewaartermijn."
-#: openforms/config/models/config.py:537 openforms/forms/models/form.py:396
+#: openforms/config/models/config.py:537 openforms/forms/models/form.py:402
msgid "all submissions removal limit"
msgstr "bewaartermijn van inzendingen"
@@ -3733,7 +3733,6 @@ msgstr ""
"unieke volgorde van het alfabet."
#: openforms/config/models/config.py:673
-#| msgid "export content"
msgid "help callout page content"
msgstr ""
@@ -3989,7 +3988,7 @@ msgstr "stijlen"
#: openforms/config/templates/admin/config/overview.html:8
#: openforms/config/templates/admin/config/theme/preview.html:13
#: openforms/forms/templates/admin/forms/form/export.html:13
-#: openforms/forms/templates/admin/forms/form/import_form.html:13
+#: openforms/forms/templates/admin/forms/form/import_form.html:14
#: openforms/forms/templates/admin/forms/form/migrate-payment-backend.html:18
#: openforms/forms/templates/admin/forms/formsubmissionstatistics/export_form.html:19
msgid "Home"
@@ -4008,7 +4007,7 @@ msgid "Status"
msgstr "Status"
#: openforms/config/templates/admin/config/overview.html:25
-#: openforms/forms/admin/category.py:37 openforms/forms/admin/form.py:264
+#: openforms/forms/admin/category.py:37 openforms/forms/admin/form.py:265
#: openforms/forms/api/serializers/logic/form_logic.py:132
#: openforms/submissions/api/serializers.py:244
msgid "Actions"
@@ -5421,7 +5420,7 @@ msgstr ""
" voor meer informatie."
#: openforms/emails/models.py:97 openforms/forms/admin/form_logic.py:28
-#: openforms/forms/models/form.py:450
+#: openforms/forms/models/form.py:456
#: openforms/forms/models/form_variable.py:249
#: openforms/forms/models/form_version.py:68
msgid "form"
@@ -6587,95 +6586,95 @@ msgstr "aantal formulieren"
msgid "Show forms"
msgstr "Toon formulieren"
-#: openforms/forms/admin/form.py:54
+#: openforms/forms/admin/form.py:55
msgid "has reached submission limit"
msgstr "status inzendingslimiet"
-#: openforms/forms/admin/form.py:59
+#: openforms/forms/admin/form.py:60
msgid "Available for submission"
msgstr "Accepteert nieuwe inzendingen"
-#: openforms/forms/admin/form.py:60
+#: openforms/forms/admin/form.py:61
msgid "Unavailable for submission"
msgstr "Limiet bereikt"
-#: openforms/forms/admin/form.py:78
+#: openforms/forms/admin/form.py:79
msgid "is deleted"
msgstr "is verwijderd"
-#: openforms/forms/admin/form.py:106
+#: openforms/forms/admin/form.py:107
msgid "Available forms"
msgstr "Beschikbare formulieren"
-#: openforms/forms/admin/form.py:113
+#: openforms/forms/admin/form.py:114
msgid "Deleted forms"
msgstr "Verwijderde formulieren"
-#: openforms/forms/admin/form.py:268
+#: openforms/forms/admin/form.py:269
msgid "Show form"
msgstr "Toon formulier"
-#: openforms/forms/admin/form.py:275
+#: openforms/forms/admin/form.py:276
msgid "Live"
msgstr "Live"
-#: openforms/forms/admin/form.py:299
+#: openforms/forms/admin/form.py:300
#, python-brace-format
msgid "{} {} was successfully copied"
msgstr "{} {} is met succes gekopieerd"
-#: openforms/forms/admin/form.py:334
+#: openforms/forms/admin/form.py:335
#, python-brace-format
msgid "{} {} was successfully exported"
msgstr "{} {} is met succes geëxporteerd"
-#: openforms/forms/admin/form.py:357
+#: openforms/forms/admin/form.py:358
#: openforms/forms/admin/form_definition.py:82
#, python-format
msgid "Copy selected %(verbose_name_plural)s"
msgstr "Kopieer geselecteerde %(verbose_name_plural)s"
-#: openforms/forms/admin/form.py:365
+#: openforms/forms/admin/form.py:366
#, python-brace-format
msgid "Copied {count} {verbose_name} object."
msgid_plural "Copied {count} {verbose_name} objects."
msgstr[0] "{count} {verbose_name} object gekopieerd"
msgstr[1] "{count} {verbose_name}objecten gekopieerd"
-#: openforms/forms/admin/form.py:375
+#: openforms/forms/admin/form.py:376
#, python-format
msgid "Set selected %(verbose_name_plural)s to maintenance mode"
msgstr ""
"Schakel de onderhoudsmodus in voor geselecteerde %(verbose_name_plural)s"
-#: openforms/forms/admin/form.py:382
+#: openforms/forms/admin/form.py:383
#, python-brace-format
msgid "Set {count} {verbose_name} object to maintenance mode"
msgid_plural "Set {count} {verbose_name} objects to maintenance mode"
msgstr[0] "Onderhoudsmodus aangezet voor {count} {verbose_name} object "
msgstr[1] "Onderhoudsmodus aangezet voor {count} {verbose_name} objecten"
-#: openforms/forms/admin/form.py:391
+#: openforms/forms/admin/form.py:392
#, python-format
msgid "Remove %(verbose_name_plural)s from maintenance mode"
msgstr ""
"Schakel de onderhoudsmodus uit voor geselecteerde %(verbose_name_plural)s."
-#: openforms/forms/admin/form.py:397
+#: openforms/forms/admin/form.py:398
#, python-brace-format
msgid "Removed {count} {verbose_name} object from maintenance mode"
msgid_plural "Removed {count} {verbose_name} objects from maintenance mode"
msgstr[0] "Onderhoudsmodus uitgezet voor {count} {verbose_name} object"
msgstr[1] "Onderhoudsmodus uitgezet voor {count} {verbose_name} objecten"
-#: openforms/forms/admin/form.py:446
+#: openforms/forms/admin/form.py:447
#: openforms/forms/templates/admin/forms/form/export.html:15
#: openforms/forms/templates/admin/forms/form/export.html:19
#: openforms/forms/templates/admin/forms/form/export.html:22
msgid "Export forms"
msgstr "Formulieren exporteren"
-#: openforms/forms/admin/form.py:452
+#: openforms/forms/admin/form.py:453
msgid ""
"Please configure your email address in your admin profile before requesting "
"a bulk export"
@@ -6707,36 +6706,40 @@ msgstr "ingezonden tussen"
msgid "Red"
msgstr "Rood"
-#: openforms/forms/admin/tasks.py:78
+#: openforms/forms/admin/tasks.py:80
msgid "Forms export ready"
msgstr "Export formulieren gereed"
-#: openforms/forms/admin/views.py:41
+#: openforms/forms/admin/views.py:43
msgid "Anonymize form configuration"
msgstr "Formulierinstellingen anonimiseren"
-#: openforms/forms/admin/views.py:45
+#: openforms/forms/admin/views.py:47
#: openforms/forms/api/serializers/form.py:701
msgid ""
"Whether sensative form configuration should be anonymized during exporting."
msgstr "Anonimiseer gevoelige formulierinstellingen bij het exporteren."
-#: openforms/forms/admin/views.py:49
+#: openforms/forms/admin/views.py:51 openforms/forms/forms/form.py:19
msgid "Form configuration"
msgstr "Formulierinstellingen"
-#: openforms/forms/admin/views.py:60
+#: openforms/forms/admin/views.py:62
#: openforms/forms/api/serializers/form.py:708
+#: openforms/forms/api/serializers/form.py:727
+#: openforms/forms/forms/form.py:30
msgid ""
"Which form configuration should be included in the export file content."
msgstr "Welke formulierinstellingen meegenomen worden bij het exporteren."
-#: openforms/forms/admin/views.py:64
+#: openforms/forms/admin/views.py:66 openforms/forms/forms/form.py:34
msgid "Additional form configuration"
msgstr "Aanvullende formulierinstellingen"
-#: openforms/forms/admin/views.py:69
+#: openforms/forms/admin/views.py:71
#: openforms/forms/api/serializers/form.py:715
+#: openforms/forms/api/serializers/form.py:745
+#: openforms/forms/forms/form.py:39
msgid ""
"Which additional form configuration should be included in the export file "
"content."
@@ -6744,24 +6747,24 @@ msgstr ""
"Welke aanvullende formulierinstellingen meegenomen worden bij het "
"exporteren."
-#: openforms/forms/admin/views.py:75
+#: openforms/forms/admin/views.py:77
msgid "Invalid form uuids."
msgstr "Ongeldige formulier-IDs."
-#: openforms/forms/admin/views.py:88
+#: openforms/forms/admin/views.py:90
msgid "Success! You will receive an email when your export is ready."
msgstr "Gelukt! U ontvangt een e-mail zodra de export gereed is."
-#: openforms/forms/admin/views.py:143
+#: openforms/forms/admin/views.py:160
#, python-brace-format
msgid "Something went wrong while importing form: {}"
msgstr "Er is iets foutgegaan bij het importeren van formulier: {}"
-#: openforms/forms/admin/views.py:156
+#: openforms/forms/admin/views.py:173
msgid "Form successfully imported!"
msgstr "Formulier is succesvol geïmporteerd"
-#: openforms/forms/admin/views.py:158
+#: openforms/forms/admin/views.py:175
msgid ""
"The bulk import is being processed! The imported forms will soon be "
"available."
@@ -6802,12 +6805,10 @@ msgid "Help callout page display"
msgstr ""
#: openforms/forms/api/serializers/form.py:147
-#| msgid "The display label of the catalogue"
msgid "When to display the help callout page."
msgstr ""
#: openforms/forms/api/serializers/form.py:152
-#| msgid "export content"
msgid "Help callout page content"
msgstr ""
@@ -6869,7 +6870,7 @@ msgid "URL to the product in the Open Forms API"
msgstr "URL van het product in de Open Formulieren API"
#: openforms/forms/api/serializers/form.py:234
-#: openforms/forms/models/form.py:134
+#: openforms/forms/models/form.py:140
msgid "payment backend options"
msgstr "betaalprovider backend opties"
@@ -6909,33 +6910,61 @@ msgstr ""
"Het bestand dat het formulier, de formulierdefinities en de formulierstappen"
" bevat."
-#: openforms/forms/api/serializers/form.py:727
+#: openforms/forms/api/serializers/form.py:732
+#: openforms/forms/forms/form.py:44
+#| msgid "form definition"
+msgid "Re-use form definitions"
+msgstr "Hergebruik formulierdefinities"
+
+#: openforms/forms/api/serializers/form.py:736
+#: openforms/forms/forms/form.py:48
+msgid ""
+"Whether to re-use existing form definitions or create new form definitions "
+"for each form definition in the import file. (If no matching reusable form "
+"definition is found, a new one will be created.)"
+msgstr ""
+"Of bestaande formulierdefinities worden hergebruikt of dat er voor elke "
+"formulierdefinitie in het importbestand een nieuwe formulierdefinitie worden"
+" aangemaakt. (Als er geen overeenkomende herbruikbare formulierdefinitie "
+"wordt gevonden, wordt er een nieuwe aangemaakt.)"
+
+#: openforms/forms/api/serializers/form.py:751
+#| msgid "The uuid of the imported form."
+msgid "Which theme should be used for the imported forms."
+msgstr "Welk stijl gebruikt zal worden voor de geïmporteerde formulieren."
+
+#: openforms/forms/api/serializers/form.py:755
+#| msgid "The uuid of the imported form."
+msgid "Which category should be applied to the imported forms."
+msgstr "Welke categorie toegepast zal worden op de geïmporteerde formulieren."
+
+#: openforms/forms/api/serializers/form.py:760
msgid "The uuid of the imported form."
msgstr "ID van het geïmporteerde formulier."
-#: openforms/forms/api/serializers/form.py:732
+#: openforms/forms/api/serializers/form.py:765
msgid "Registration backend key"
msgstr "Registratiebackend-sleutel"
-#: openforms/forms/api/serializers/form.py:734
+#: openforms/forms/api/serializers/form.py:767
msgid "The registration backend key for which to generate the schema."
msgstr ""
"De registratiebackend-sleutel waarvoor het schema moet worden gegenereerd."
-#: openforms/forms/api/serializers/form.py:739
+#: openforms/forms/api/serializers/form.py:772
msgid "Registration backend identifier"
msgstr "Registratiebackend-identificatie"
-#: openforms/forms/api/serializers/form.py:744
+#: openforms/forms/api/serializers/form.py:777
msgid "Registration backend options"
msgstr "Registratiebackend-instellingen"
-#: openforms/forms/api/serializers/form.py:756
+#: openforms/forms/api/serializers/form.py:789
#, python-brace-format
msgid "Backend with key '{key}' does not exist for form '{form}'"
msgstr "Backend met sleutel '{key}' bestaat niet voor formulier '{form}'"
-#: openforms/forms/api/serializers/form.py:773
+#: openforms/forms/api/serializers/form.py:806
#, python-brace-format
msgid "Backend with id '{backend}' does not allow JSON schema generation"
msgstr ""
@@ -7632,9 +7661,9 @@ msgstr "Het UUID van de formulierversie"
msgid "List form versions"
msgstr "Formulierversies weergeven"
-#: openforms/forms/api/viewsets.py:785
-#: openforms/forms/templates/admin/forms/form/import_form.html:15
-#: openforms/forms/templates/admin/forms/form/import_form.html:20
+#: openforms/forms/api/viewsets.py:788
+#: openforms/forms/templates/admin/forms/form/import_form.html:16
+#: openforms/forms/templates/admin/forms/form/import_form.html:21
msgid "Import form"
msgstr "Formulier importeren"
@@ -7727,12 +7756,10 @@ msgid "Single step"
msgstr "Enkele stap"
#: openforms/forms/constants.py:71
-#| msgid "Go to the payment page"
msgid "Before the start page"
msgstr ""
#: openforms/forms/constants.py:72
-#| msgid "Go to the payment page"
msgid "After the start page"
msgstr ""
@@ -7740,14 +7767,34 @@ msgstr ""
msgid "Never"
msgstr ""
-#: openforms/forms/forms/form.py:7
+#: openforms/forms/forms/form.py:14
msgid "file"
msgstr "bestand"
-#: openforms/forms/forms/form.py:9
+#: openforms/forms/forms/form.py:16
msgid "Upload your exported ZIP-file."
msgstr "Upload het geëxporteerde ZIP-bestand."
+#: openforms/forms/forms/form.py:54
+#| msgid "Themes"
+msgid "Theme"
+msgstr "Stijl"
+
+#: openforms/forms/forms/form.py:56
+#| msgid "The uuid of the imported form."
+msgid "Which theme should be used for the imported form."
+msgstr "Welk stijl gebruikt zal worden voor het geïmporteerde formulier."
+
+#: openforms/forms/forms/form.py:60
+#| msgid "category"
+msgid "Category"
+msgstr "Categorie"
+
+#: openforms/forms/forms/form.py:62
+#| msgid "The uuid of the imported form."
+msgid "Which category should be applied to the imported form."
+msgstr "Welke categorie toegepast zal worden op het geïmporteerde formulier."
+
#: openforms/forms/forms/form_statistics.py:38
msgid "Successfully registered"
msgstr "Successvol geregistreerd"
@@ -7789,7 +7836,7 @@ msgstr "Exporteer inzendingen die op of voor deze datum ingestuurd zijn."
#: openforms/forms/forms/form_statistics.py:72
#: openforms/forms/templates/admin/forms/form/export.html:14
-#: openforms/forms/templates/admin/forms/form/import_form.html:14
+#: openforms/forms/templates/admin/forms/form/import_form.html:15
#: openforms/forms/templates/admin/forms/form/migrate-payment-backend.html:19
msgid "Forms"
msgstr "Formulieren"
@@ -7886,21 +7933,21 @@ msgstr "Eenvoudige naam"
msgid "categories"
msgstr "categorieën"
-#: openforms/forms/models/form.py:83
+#: openforms/forms/models/form.py:89
#: openforms/forms/models/form_definition.py:42
msgid "internal name"
msgstr "interne naam"
-#: openforms/forms/models/form.py:86
+#: openforms/forms/models/form.py:92
#: openforms/forms/models/form_definition.py:45
msgid "internal name for management purposes"
msgstr "interne naam voor beheerdoeleinden "
-#: openforms/forms/models/form.py:89
+#: openforms/forms/models/form.py:95
msgid "internal remarks"
msgstr "interne opmerkingen"
-#: openforms/forms/models/form.py:91
+#: openforms/forms/models/form.py:97
msgid ""
"Remarks or intentions about the form. Can also be used to save notes for "
"later use or for another admin user."
@@ -7908,17 +7955,17 @@ msgstr ""
"Opmerkingen of intenties over het formulier. Kan ook gebruikt worden voor "
"het maken van notities voor later gebruik of voor een andere beheerder."
-#: openforms/forms/models/form.py:98
+#: openforms/forms/models/form.py:104
#: openforms/forms/models/form_definition.py:47
#: openforms/forms/models/form_step.py:33
msgid "slug"
msgstr "URL-deel"
-#: openforms/forms/models/form.py:101
+#: openforms/forms/models/form.py:107
msgid "form type"
msgstr "formuliertype"
-#: openforms/forms/models/form.py:105
+#: openforms/forms/models/form.py:111
msgid ""
"The type of the form. The choices are regular, appointment or a single step "
"form. Depending on the choice a different form design is required/rendered."
@@ -7927,11 +7974,11 @@ msgstr ""
"standaard, afspraak of een formulier met één stap. Afhankelijk van de keuze "
"zijn andere formulierinstellingen beschikbaar/verplicht."
-#: openforms/forms/models/form.py:123
+#: openforms/forms/models/form.py:129
msgid "form theme"
msgstr "formulierstijl"
-#: openforms/forms/models/form.py:125
+#: openforms/forms/models/form.py:131
msgid ""
"Apply a specific appearance configuration to the form. If left blank, then "
"the globally configured default is applied."
@@ -7939,33 +7986,33 @@ msgstr ""
"Pas een specifieke stijl toe op het formulier. Indien geen optie gekozen is,"
" dan wordt de globale instelling toegepast."
-#: openforms/forms/models/form.py:129
+#: openforms/forms/models/form.py:135
msgid "translation enabled"
msgstr "meertaligheid ingeschakeld"
-#: openforms/forms/models/form.py:132 openforms/forms/models/form.py:534
+#: openforms/forms/models/form.py:138 openforms/forms/models/form.py:540
msgid "payment backend"
msgstr "betaalprovider backend"
-#: openforms/forms/models/form.py:139
+#: openforms/forms/models/form.py:145
msgid "price variable key"
msgstr "sleutel prijsvariabele"
-#: openforms/forms/models/form.py:142
+#: openforms/forms/models/form.py:148
msgid "Key of the variable that contains the calculated submission price."
msgstr ""
"Sleutel van de variabele die de (berekende) kostprijs van de inzending "
"bevat."
-#: openforms/forms/models/form.py:149
+#: openforms/forms/models/form.py:155
msgid "automatic login"
msgstr "automatisch inloggen"
-#: openforms/forms/models/form.py:154
+#: openforms/forms/models/form.py:160
msgid "maximum allowed submissions"
msgstr "maximum aantal inzendingen"
-#: openforms/forms/models/form.py:158
+#: openforms/forms/models/form.py:164
msgid ""
"Maximum number of allowed submissions per form. Leave this empty if no limit"
" is needed."
@@ -7973,11 +8020,11 @@ msgstr ""
"Het maximum aantal inzendingen die toegestaan zijn voor het formulier. Laat "
"dit veld leeg als er geen beperking is."
-#: openforms/forms/models/form.py:162
+#: openforms/forms/models/form.py:168
msgid "submissions counter"
msgstr "aantal inzendingen"
-#: openforms/forms/models/form.py:165
+#: openforms/forms/models/form.py:171
msgid ""
"Counter to track how many submissions have been completed for the specific "
"form. This works in combination with the maximum allowed submissions per "
@@ -7987,7 +8034,7 @@ msgstr ""
"Deze werkt samen met de inzendingslimiet en kan gereset worden via de "
"frontend."
-#: openforms/forms/models/form.py:173
+#: openforms/forms/models/form.py:179
msgid ""
"The content of the submission confirmation page. It can contain variables "
"that will be templated from the submitted form data. If not specified, the "
@@ -7997,12 +8044,12 @@ msgstr ""
"de ingediende formuliergegevens worden weergegeven. Indien niet opgegeven "
"dan wordt de globale bevestingspagina gebruikt."
-#: openforms/forms/models/form.py:182
+#: openforms/forms/models/form.py:188
#: openforms/submissions/api/serializers.py:157
msgid "submission allowed"
msgstr "inzenden toegestaan"
-#: openforms/forms/models/form.py:186
+#: openforms/forms/models/form.py:192
msgid ""
"Whether the user is allowed to submit this form or not, and whether the "
"overview page should be shown if they are not."
@@ -8010,31 +8057,31 @@ msgstr ""
"Geeft aan of de gebruiker het formulier kan verzenden en of de "
"overzichtspagina getoond wordt als het formulier niet verzonden kan worden."
-#: openforms/forms/models/form.py:192
+#: openforms/forms/models/form.py:198
msgid "suspension allowed"
msgstr "tussentijds opslaan"
-#: openforms/forms/models/form.py:194
+#: openforms/forms/models/form.py:200
msgid "Whether the user is allowed to suspend this form or not."
msgstr ""
"Geeft aan of de gebruiker het formulier tussentijds kan opslaan/pauzeren of "
"niet."
-#: openforms/forms/models/form.py:197
+#: openforms/forms/models/form.py:203
msgid "show progress indicator"
msgstr "toon formulierstappen"
-#: openforms/forms/models/form.py:200
+#: openforms/forms/models/form.py:206
msgid "Whether the step progression should be displayed in the UI or not."
msgstr ""
"Indien aangevinkt dan worden de stappen van het formulier aan de gebruiker "
"getoond."
-#: openforms/forms/models/form.py:204
+#: openforms/forms/models/form.py:210
msgid "show summary of the progress"
msgstr "toon voortgangsoverzicht"
-#: openforms/forms/models/form.py:207
+#: openforms/forms/models/form.py:213
msgid ""
"Whether to display the short progress summary, indicating the current step "
"number and total amount of steps."
@@ -8043,30 +8090,30 @@ msgstr ""
" de huidige stap en het totaal aantal stappen, typisch onder de "
"formuliertitel."
-#: openforms/forms/models/form.py:212
+#: openforms/forms/models/form.py:218
msgid "display main website link"
msgstr "toon hoofdsite link"
-#: openforms/forms/models/form.py:215
+#: openforms/forms/models/form.py:221
msgid ""
"Display the link to the main website on the submission confirmation page."
msgstr "Toon de link naar de hoofdsite op de bevestigingspagina na inzenden."
-#: openforms/forms/models/form.py:219
+#: openforms/forms/models/form.py:225
msgid "include confirmation page content in PDF"
msgstr "voeg de \"bevestigingspaginatekst\" toe in de bevestigings-PDF"
-#: openforms/forms/models/form.py:221
+#: openforms/forms/models/form.py:227
msgid "Display the instruction from the confirmation page in the PDF."
msgstr ""
"Vink aan om de inhoud van het \"bevestigingspaginatekst\"-veld toe te voegen"
" aan de PDF met gegevens ter bevestiging."
-#: openforms/forms/models/form.py:224
+#: openforms/forms/models/form.py:230
msgid "send confirmation email"
msgstr "verstuur bevestigingsmail"
-#: openforms/forms/models/form.py:226
+#: openforms/forms/models/form.py:232
msgid ""
"Whether a confirmation email should be sent to the end user filling in the "
"form."
@@ -8074,7 +8121,7 @@ msgstr ""
"Geef aan of er een bevestigingsmail naar de formulierinzender verstuurd "
"dient te worden."
-#: openforms/forms/models/form.py:256
+#: openforms/forms/models/form.py:262
msgid ""
"The text that will be displayed at the start of the form to indicate the "
"user can begin to fill in the form. Leave blank to get value from global "
@@ -8083,11 +8130,11 @@ msgstr ""
"Het label van de knop om het formulier te starten. Laat leeg om de waarde "
"van de algemene configuratie te gebruiken."
-#: openforms/forms/models/form.py:262
+#: openforms/forms/models/form.py:268
msgid "previous text"
msgstr "Vorige stap-label"
-#: openforms/forms/models/form.py:266
+#: openforms/forms/models/form.py:272
msgid ""
"The text that will be displayed in the overview page to go to the previous "
"step. Leave blank to get value from global configuration."
@@ -8095,7 +8142,7 @@ msgstr ""
"Het label van de knop op de overzichtspagina om naar de vorige stap te gaan."
" Laat leeg om de waarde van de algemene configuratie te gebruiken."
-#: openforms/forms/models/form.py:276
+#: openforms/forms/models/form.py:282
msgid ""
"The text that will be displayed in the overview page to change a certain "
"step. Leave blank to get value from global configuration."
@@ -8103,7 +8150,7 @@ msgstr ""
"Het label de link op de overzichtspagina om een bepaalde stap te wijzigen. "
"Laat leeg om de waarde van de algemene configuratie te gebruiken."
-#: openforms/forms/models/form.py:286
+#: openforms/forms/models/form.py:292
msgid ""
"The text that will be displayed in the overview page to confirm the form is "
"filled in correctly. Leave blank to get value from global configuration."
@@ -8111,11 +8158,11 @@ msgstr ""
"Het label van de knop op de overzichtspagina om het formulier in te dienen. "
"Laat leeg om de waarde van de algemene configuratie te gebruiken."
-#: openforms/forms/models/form.py:294
+#: openforms/forms/models/form.py:300
msgid "introduction page"
msgstr "introductiepagina"
-#: openforms/forms/models/form.py:296
+#: openforms/forms/models/form.py:302
msgid ""
"Content for the introduction page that leads to the start page of the form. "
"Leave blank to disable the introduction page."
@@ -8123,11 +8170,11 @@ msgstr ""
"Inhoud voor de introductiepagina die naar de formulierstartpagina leidt. "
"Laat deze leeg om de introductiepagina uit te schakelen."
-#: openforms/forms/models/form.py:304
+#: openforms/forms/models/form.py:310
msgid "explanation template"
msgstr "Toelichtingssjabloon"
-#: openforms/forms/models/form.py:306
+#: openforms/forms/models/form.py:312
msgid ""
"Content that will be shown on the start page of the form, below the title "
"and above the log in text."
@@ -8135,31 +8182,31 @@ msgstr ""
"Inhoud die op de formulierstartpagina wordt getoond, onder de titel en boven"
" de startknop(pen)."
-#: openforms/forms/models/form.py:314
+#: openforms/forms/models/form.py:320
msgid "maintenance mode"
msgstr "onderhoudsmodus"
-#: openforms/forms/models/form.py:317
+#: openforms/forms/models/form.py:323
msgid "Users will not be able to start the form if it is in maintenance mode."
msgstr "Gebruikers kunnen een formulier in onderhoudsmodus niet starten."
-#: openforms/forms/models/form.py:322
+#: openforms/forms/models/form.py:328
msgid "activate on"
msgstr "Activeren op"
-#: openforms/forms/models/form.py:325
+#: openforms/forms/models/form.py:331
msgid "Date and time on which the form should be activated."
msgstr "Datum en tijdstip waarop het formulier geactiveerd moet worden."
-#: openforms/forms/models/form.py:328
+#: openforms/forms/models/form.py:334
msgid "deactivate on"
msgstr "Deactiveren op"
-#: openforms/forms/models/form.py:331
+#: openforms/forms/models/form.py:337
msgid "Date and time on which the form should be deactivated."
msgstr "Datum en tijdstip waarop het formulier gedeactiveerd moet worden."
-#: openforms/forms/models/form.py:341
+#: openforms/forms/models/form.py:347
msgid ""
"Amount of days successful submissions of this form will remain before being "
"removed. Leave blank to use value in General Configuration."
@@ -8167,7 +8214,7 @@ msgstr ""
"Aantal dagen dat een voltooide inzending bewaard blijft. Laat leeg om de "
"waarde van de algemene configuratie te gebruiken."
-#: openforms/forms/models/form.py:351
+#: openforms/forms/models/form.py:357
msgid ""
"How successful submissions of this form will be removed after the limit. "
"Leave blank to use value in General Configuration."
@@ -8175,7 +8222,7 @@ msgstr ""
"Geeft aan hoe voltooide inzendingen worden opgeschoond na de bewaartermijn. "
"Laat leeg om de waarde van de algemene configuratie te gebruiken."
-#: openforms/forms/models/form.py:361
+#: openforms/forms/models/form.py:367
msgid ""
"Amount of days incomplete submissions of this form will remain before being "
"removed. Leave blank to use value in General Configuration."
@@ -8183,7 +8230,7 @@ msgstr ""
"Aantal dagen dat een sessie bewaard blijft. Laat leeg om de waarde van de "
"algemene configuratie te gebruiken."
-#: openforms/forms/models/form.py:371
+#: openforms/forms/models/form.py:377
msgid ""
"How incomplete submissions of this form will be removed after the limit. "
"Leave blank to use value in General Configuration."
@@ -8191,7 +8238,7 @@ msgstr ""
"Geeft aan hoe sessies worden opgeschoond na de bewaartermijn. Laat leeg om "
"de waarde van de algemene configuratie te gebruiken."
-#: openforms/forms/models/form.py:381
+#: openforms/forms/models/form.py:387
msgid ""
"Amount of days errored submissions of this form will remain before being "
"removed. Leave blank to use value in General Configuration."
@@ -8200,7 +8247,7 @@ msgstr ""
"afhandeling) bewaard blijft. Laat leeg om de waarde van de algemene "
"configuratie te gebruiken."
-#: openforms/forms/models/form.py:391
+#: openforms/forms/models/form.py:397
msgid ""
"How errored submissions of this form will be removed after the limit. Leave "
"blank to use value in General Configuration."
@@ -8209,7 +8256,7 @@ msgstr ""
"worden opgeschoond na de bewaartermijn. Laat leeg om de waarde van de "
"algemene configuratie te gebruiken."
-#: openforms/forms/models/form.py:401
+#: openforms/forms/models/form.py:407
msgid ""
"Amount of days when all submissions of this form will be permanently "
"deleted. Leave blank to use value in General Configuration."
@@ -8218,21 +8265,21 @@ msgstr ""
"verwijderd wordt. Laat leeg om de waarde van de algemene configuratie te "
"gebruiken."
-#: openforms/forms/models/form.py:408
+#: openforms/forms/models/form.py:414
msgid "help callout page display"
msgstr ""
-#: openforms/forms/models/form.py:412
+#: openforms/forms/models/form.py:418
msgid ""
"When to display the help callout page. Note that this field is disabled when"
" the help callout page content in the global configuration is not specified."
msgstr ""
-#: openforms/forms/models/form.py:421
+#: openforms/forms/models/form.py:427
msgid "enable new logic rule evaluation"
msgstr "gebruik nieuwe logica-evaluatie"
-#: openforms/forms/models/form.py:424
+#: openforms/forms/models/form.py:430
msgid ""
"Enabling this will analyze logic rules and re-order them according to their "
"dependency on other logic rules (happens when the form is saved). Each rule "
@@ -8246,76 +8293,76 @@ msgstr ""
"Daarnaast zullen ook andere snelheidsverbeteringen toegepast worden waar "
"mogelijk."
-#: openforms/forms/models/form.py:451
+#: openforms/forms/models/form.py:457
msgid "forms"
msgstr "formulieren"
-#: openforms/forms/models/form.py:455
+#: openforms/forms/models/form.py:461
#, python-brace-format
msgid "{name} (deleted)"
msgstr "{name} (verwijderd)"
-#: openforms/forms/models/form.py:515 openforms/forms/models/form.py:531
-#: openforms/forms/models/form.py:544
+#: openforms/forms/models/form.py:521 openforms/forms/models/form.py:537
+#: openforms/forms/models/form.py:550
#, python-brace-format
msgid "{backend} (invalid)"
msgstr "{backend} (ongeldig)"
-#: openforms/forms/models/form.py:522
+#: openforms/forms/models/form.py:528
msgid "registrations"
msgstr "Registraties"
-#: openforms/forms/models/form.py:549
+#: openforms/forms/models/form.py:555
msgid "logins"
msgstr "inloggen"
-#: openforms/forms/models/form.py:594 openforms/forms/models/form.py:609
+#: openforms/forms/models/form.py:600 openforms/forms/models/form.py:615
#: openforms/forms/models/form_definition.py:106
#: openforms/forms/models/form_definition.py:124
#, python-brace-format
msgid "{name} (copy)"
msgstr "{name} (kopie)"
-#: openforms/forms/models/form.py:598
+#: openforms/forms/models/form.py:604
#: openforms/forms/models/form_definition.py:110
#, python-brace-format
msgid "{slug}-copy"
msgstr "{slug}-kopie"
-#: openforms/forms/models/form.py:721
+#: openforms/forms/models/form.py:795
#, python-brace-format
msgid "Restored form version {version} (from {created})."
msgstr "Formulierversie {version} hersteld (van {created})."
-#: openforms/forms/models/form.py:843
+#: openforms/forms/models/form.py:917
msgid "export content"
msgstr "Exportinhoud"
-#: openforms/forms/models/form.py:845
+#: openforms/forms/models/form.py:919
msgid "Zip file containing all the exported forms."
msgstr "Het ZIP-bestand met daarin alle geëxporteerde formulieren."
-#: openforms/forms/models/form.py:848
+#: openforms/forms/models/form.py:922
msgid "date time requested"
msgstr "Moment van aanvraag"
-#: openforms/forms/models/form.py:849
+#: openforms/forms/models/form.py:923
msgid "The date and time on which the bulk export was requested."
msgstr "De datum en tijdstip waarop de bulk export aangevraagd is."
-#: openforms/forms/models/form.py:855
+#: openforms/forms/models/form.py:929
msgid "The user that requested the download."
msgstr "De gebruiker die de download heeft aangevraagd."
-#: openforms/forms/models/form.py:862
+#: openforms/forms/models/form.py:936
msgid "forms export"
msgstr "formulierenexport"
-#: openforms/forms/models/form.py:863
+#: openforms/forms/models/form.py:937
msgid "forms exports"
msgstr "Formulierexports"
-#: openforms/forms/models/form.py:866
+#: openforms/forms/models/form.py:940
#, python-format
msgid "Bulk export requested by %(username)s on %(datetime)s"
msgstr "Bulk-export aangevraagd door %(username)s op %(datetime)s"
@@ -8772,16 +8819,26 @@ msgstr ""
" e-mail met een link naar het ZIP-bestand dat alle geëxporteerde formulieren"
" bevat.
All hyperlinks in the email templates, whose (sub)domains are not "
+"provided in the global configuration allowlist, will be removed during "
+"import.
"
+msgstr ""
+"
Alle hyperlinks in de e-mailsjablonen waarvan de (sub)domeinen niet zijn "
+"opgenomen in de toelatingslijst van de algemene configuratie, worden tijdens"
+" het importeren verwijderd.
"
+
+#: openforms/forms/templates/admin/forms/form/import_form.html:65
msgid "Import"
msgstr "Importeren"
diff --git a/src/openforms/forms/admin/tasks.py b/src/openforms/forms/admin/tasks.py
index bb41d8c0a9..8ca58b1c8b 100644
--- a/src/openforms/forms/admin/tasks.py
+++ b/src/openforms/forms/admin/tasks.py
@@ -18,16 +18,18 @@
from openforms.celery import app
from openforms.emails.utils import send_mail_html
from openforms.forms.import_export.export_form import export_form
+from openforms.forms.import_export.import_form import import_form
from openforms.forms.import_export.typing import (
FormExportOptions,
FormExportOptionsData,
+ FormImportOptions,
+ FormImportOptionsData,
)
from openforms.logging import audit_logger
from openforms.utils.urls import build_absolute_uri
from ..models import Form
from ..models.form import FormsExport
-from ..utils import import_form
logger = structlog.stdlib.get_logger(__name__)
@@ -83,7 +85,9 @@ def process_forms_export(
@app.task(ignore_result=True)
-def process_forms_import(import_file: str, user_id: int) -> None:
+def process_forms_import(
+ import_file: str, user_id: int, import_options: FormImportOptionsData
+) -> None:
failed_files: list[tuple[str, object]] = []
# This deletes the temp dir once the context manager is exited
with tempfile.TemporaryDirectory() as temp_dir:
@@ -92,7 +96,8 @@ def process_forms_import(import_file: str, user_id: int) -> None:
try:
# This normalises the path before extracting the files (to avoid writing outside the temp_dir)
import_form(
- zip_file.extract(member=zipped_form_file, path=temp_dir)
+ zip_file.extract(member=zipped_form_file, path=temp_dir),
+ import_options=FormImportOptions(**import_options),
)
except ValidationError as exc:
filename = Path(zipped_form_file.filename).name
diff --git a/src/openforms/forms/admin/views.py b/src/openforms/forms/admin/views.py
index d1fda317d9..03eeb5240b 100644
--- a/src/openforms/forms/admin/views.py
+++ b/src/openforms/forms/admin/views.py
@@ -22,16 +22,18 @@
from privates.storages import private_media_storage
from rest_framework.exceptions import ValidationError
+from openforms.forms.import_export.import_form import import_form
from openforms.forms.import_export.typing import (
AdditionalFormConfigurationOptions,
FormConfigurationOptions,
+ FormImportOptions,
+ FormImportOptionsData,
)
from openforms.logging import audit_logger
from ..forms import ExportStatisticsForm
from ..forms.form import FormImportForm
from ..models import Form, FormsExport, FormSubmissionStatistics
-from ..utils import import_form
from .tasks import process_forms_export, process_forms_import
@@ -134,9 +136,24 @@ def form_valid(self, form):
import_file = form.cleaned_data["file"]
is_bulk_import = self.get_is_bulk_import(import_file)
+ import_options = {
+ field_name: form.cleaned_data[field_name]
+ for field_name in (
+ "form_configuration",
+ "reuse_form_definitions",
+ "additional_form_configuration",
+ "theme",
+ "category",
+ )
+ if field_name in form.cleaned_data
+ }
+
if not is_bulk_import:
try:
- import_form(import_file)
+ import_form(
+ import_file,
+ import_options=FormImportOptions(**import_options),
+ )
except ValidationError as exc:
messages.error(
self.request,
@@ -144,7 +161,7 @@ def form_valid(self, form):
)
return super().form_invalid(form)
else:
- self._bulk_import_forms(import_file)
+ self._bulk_import_forms(import_file, import_options)
return super().form_valid(form)
@@ -158,11 +175,11 @@ def get_success_message(self, cleaned_data):
"The bulk import is being processed! The imported forms will soon be available."
)
- def _bulk_import_forms(self, import_file):
+ def _bulk_import_forms(self, import_file, import_options: FormImportOptionsData):
name = f"imports/import_forms_{uuid4()}.zip"
filename = private_media_storage.save(name, import_file)
- process_forms_import.delay(filename, self.request.user.id)
+ process_forms_import.delay(filename, self.request.user.id, import_options)
@method_decorator(staff_member_required, name="dispatch")
diff --git a/src/openforms/forms/api/serializers/form.py b/src/openforms/forms/api/serializers/form.py
index 3e71bea31a..1aba22628a 100644
--- a/src/openforms/forms/api/serializers/form.py
+++ b/src/openforms/forms/api/serializers/form.py
@@ -543,61 +543,8 @@ def to_internal_value(self, attrs):
self.convert_objects_api_group(attrs)
return super().to_internal_value(attrs)
- def _handle_import(self, attrs) -> None:
- # we're not importing, nothing to do
- if not self.context.get("is_import", False) or not hasattr(
- self, "initial_data"
- ):
- return
-
- if (
- "authentication_backends" not in self.initial_data
- and "authentication_backend_options" not in self.initial_data
- ):
- return
-
- # Make sure `auth_backends` exists
- attrs["auth_backends"] = attrs.get("auth_backends", [])
- auth_backends_map = {}
-
- # Pre-fill the map with the `auth_backends` values
- for auth_backend in attrs["auth_backends"]:
- auth_backends_map[auth_backend["backend"]] = auth_backend
-
- # Collect all the backends that should be transformed to `auth_backends`
- if "authentication_backends" in self.initial_data:
- for plugin in self.initial_data["authentication_backends"]:
- # Add plugin if it's not already in the map
- if plugin not in auth_backends_map:
- auth_backends_map[plugin] = {
- "backend": plugin,
- "options": None,
- }
-
- if "authentication_backend_options" in self.initial_data:
- for plugin, options in self.initial_data[
- "authentication_backend_options"
- ].items():
- if plugin not in auth_backends_map:
- auth_backends_map[plugin] = {
- "backend": plugin,
- "options": options,
- }
- continue
-
- if auth_backends_map[plugin]["options"] is None:
- auth_backends_map[plugin]["options"] = options
-
- validated_auth_backends = []
- for config in auth_backends_map.values():
- validated_auth_backends.append(
- FormAuthenticationBackendSerializer().validate(config)
- )
- attrs["auth_backends"] = validated_auth_backends
-
def validate(self, attrs):
super().validate(attrs)
- self._handle_import(attrs)
self.validate_backend_options(
attrs, "payment_backend", "payment_backend_options", payment_register
@@ -766,6 +713,39 @@ class FormImportSerializer(serializers.Serializer):
file = serializers.FileField(
help_text=_("The file that contains the form, form definitions and form steps.")
)
+ form_configuration = serializers.MultipleChoiceField(
+ choices=FormConfigurationOptions.choices,
+ help_text=_(
+ "Which form configuration should be included in the export file content."
+ ),
+ required=False,
+ )
+ reuse_form_definitions = serializers.BooleanField(
+ label=_("Re-use form definitions"),
+ required=False,
+ initial=True,
+ help_text=_(
+ "Whether to re-use existing form definitions or create new form definitions "
+ "for each form definition in the import file. (If no matching reusable form "
+ "definition is found, a new one will be created.)"
+ ),
+ )
+ additional_form_configuration = serializers.MultipleChoiceField(
+ required=False,
+ choices=AdditionalFormConfigurationOptions.choices,
+ help_text=_(
+ "Which additional form configuration should be included in the export file "
+ "content."
+ ),
+ )
+ theme = serializers.UUIDField(
+ required=False,
+ help_text=_("Which theme should be used for the imported forms."),
+ )
+ category = serializers.UUIDField(
+ required=False,
+ help_text=_("Which category should be applied to the imported forms."),
+ )
class FormImportResponseSerializer(serializers.Serializer):
diff --git a/src/openforms/forms/api/serializers/logic/form_logic.py b/src/openforms/forms/api/serializers/logic/form_logic.py
index daf7e817df..d637742c86 100644
--- a/src/openforms/forms/api/serializers/logic/form_logic.py
+++ b/src/openforms/forms/api/serializers/logic/form_logic.py
@@ -12,9 +12,6 @@
from openforms.api.serializers import ListWithChildSerializer
from ....constants import FormTypeChoices
-from ....disable_next_import_conversion import (
- add_form_step_uuid_to_disable_next_actions,
-)
from ....logic_analysis import (
CyclesDetected,
analyze_rules,
@@ -194,17 +191,3 @@ def __init__(self, *args, **kwargs):
related_field = self.Meta.model._meta.get_field("form")
self.fields["form"].help_text = related_field.help_text
self.fields["form"].label = related_field.verbose_name
-
- def _handle_import(self, attrs) -> None:
- if not self.context.get("is_import", False):
- return
-
- add_form_step_uuid_to_disable_next_actions(
- attrs, self.context["form_variables"].variables, self.context["form_steps"]
- )
-
- def run_validation(self, data) -> None:
- # Override `run_validation` instead of `validate`, because it runs before
- # `LogicComponentActionSerializer.validate`.
- self._handle_import(data)
- return super().run_validation(data)
diff --git a/src/openforms/forms/api/viewsets.py b/src/openforms/forms/api/viewsets.py
index 7ce38f4b11..b028a73da9 100644
--- a/src/openforms/forms/api/viewsets.py
+++ b/src/openforms/forms/api/viewsets.py
@@ -22,7 +22,8 @@
from openforms.api.pagination import PageNumberPagination
from openforms.api.serializers import ExceptionSerializer, ValidationErrorSerializer
from openforms.forms.import_export.export_form import export_form
-from openforms.forms.import_export.typing import FormExportOptions
+from openforms.forms.import_export.import_form import import_form
+from openforms.forms.import_export.typing import FormExportOptions, FormImportOptions
from openforms.translations.utils import set_language_cookie
from openforms.utils.patches.rest_framework_nested.viewsets import NestedViewSetMixin
from openforms.utils.urls import is_admin_request, reverse_plus
@@ -36,7 +37,6 @@
FormStep,
FormVersion,
)
-from ..utils import import_form
from .datastructures import FormVariableWrapper
from .documentation import get_admin_fields_markdown
from .filters import FormDefinitionFilter, FormVariableFilter
@@ -777,7 +777,10 @@ def restore(self, request, *args, **kwargs):
class FormsImportAPIView(views.APIView):
serializer_class = FormImportSerializer
- parser_classes = (parsers.FileUploadParser,)
+ parser_classes = (
+ parsers.MultiPartParser,
+ parsers.FormParser,
+ )
authentication_classes = [TokenAuthentication]
permission_classes = [FormAPIPermissions]
@@ -794,7 +797,22 @@ def post(self, request, *args, **kwargs):
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
- form_instance = import_form(serializer.validated_data["file"])
+ form_instance = import_form(
+ serializer.validated_data["file"],
+ import_options=FormImportOptions(
+ **{
+ field_name: serializer.validated_data[field_name]
+ for field_name in (
+ "form_configuration",
+ "reuse_form_definitions",
+ "additional_form_configuration",
+ "theme",
+ "category",
+ )
+ if field_name in serializer.validated_data
+ }
+ ),
+ )
assert form_instance
response_serializer = FormImportResponseSerializer(instance=form_instance)
diff --git a/src/openforms/forms/forms/form.py b/src/openforms/forms/forms/form.py
index 998640f641..7507bafaad 100644
--- a/src/openforms/forms/forms/form.py
+++ b/src/openforms/forms/forms/form.py
@@ -1,6 +1,13 @@
from django import forms
from django.utils.translation import gettext_lazy as _
+from openforms.config.models import Theme
+from openforms.forms.import_export.typing import (
+ AdditionalFormConfigurationOptions,
+ FormConfigurationOptions,
+)
+from openforms.forms.models import Category
+
class FormImportForm(forms.Form):
file = forms.FileField(
@@ -8,3 +15,50 @@ class FormImportForm(forms.Form):
required=True,
help_text=_("Upload your exported ZIP-file."),
)
+ form_configuration = forms.MultipleChoiceField(
+ label=_("Form configuration"),
+ required=False,
+ widget=forms.CheckboxSelectMultiple,
+ initial=[
+ FormConfigurationOptions.registration_backends,
+ FormConfigurationOptions.prefill,
+ FormConfigurationOptions.payment_backend,
+ FormConfigurationOptions.auth_backends,
+ ],
+ choices=FormConfigurationOptions.choices,
+ help_text=_(
+ "Which form configuration should be included in the export file content."
+ ),
+ )
+ additional_form_configuration = forms.MultipleChoiceField(
+ label=_("Additional form configuration"),
+ required=False,
+ widget=forms.CheckboxSelectMultiple,
+ choices=AdditionalFormConfigurationOptions.choices,
+ help_text=_(
+ "Which additional form configuration should be included in the export file "
+ "content."
+ ),
+ )
+ reuse_form_definitions = forms.BooleanField(
+ label=_("Re-use form definitions"),
+ required=False,
+ initial=True,
+ help_text=_(
+ "Whether to re-use existing form definitions or create new form definitions "
+ "for each form definition in the import file. (If no matching reusable form "
+ "definition is found, a new one will be created.)"
+ ),
+ )
+ theme = forms.ModelChoiceField(
+ label=_("Theme"),
+ required=False,
+ help_text=_("Which theme should be used for the imported form."),
+ queryset=Theme.objects.all(),
+ )
+ category = forms.ModelChoiceField(
+ label=_("Category"),
+ required=False,
+ help_text=_("Which category should be applied to the imported form."),
+ queryset=Category.objects.all(),
+ )
diff --git a/src/openforms/forms/import_export/import_form.py b/src/openforms/forms/import_export/import_form.py
new file mode 100644
index 0000000000..c9f98fa238
--- /dev/null
+++ b/src/openforms/forms/import_export/import_form.py
@@ -0,0 +1,424 @@
+import json
+import zipfile
+from collections.abc import Collection
+from typing import Required, TypedDict
+
+from django.conf import settings
+from django.db import transaction
+from django.utils.translation import override
+
+from rest_framework.exceptions import ValidationError
+from rest_framework.request import Request
+from rest_framework.test import APIRequestFactory
+
+from openforms.forms.api.datastructures import FormVariableWrapper
+from openforms.forms.models import (
+ Form,
+ FormDefinition,
+)
+from openforms.registrations.contrib.objects_api.constants import (
+ PLUGIN_IDENTIFIER as OBJECTS_API_PLUGIN_IDENTIFIER,
+)
+from openforms.registrations.contrib.stuf_zds.plugin import (
+ PLUGIN_IDENTIFIER as STUF_ZDS_PLUGIN_IDENTIFIER,
+)
+from openforms.registrations.contrib.zgw_apis.plugin import (
+ PLUGIN_IDENTIFIER as ZGW_APIS_PLUGIN_IDENTIFIER,
+)
+
+from .matchers.form_definition import FormDefinitionMatcher
+from .serializers import (
+ FormDefinitionImportSerializer,
+ FormImportSerializer,
+ FormLogicImportSerializer,
+ FormStepImportSerializer,
+ FormVariableImportSerializer,
+)
+from .typing import FormImportOptions
+from .utils import import_additional_form_configuration_data
+
+EXPECTED_RESOURCES = (
+ "forms",
+ "formDefinitions",
+ "formSteps",
+ "formVariables",
+ "formLogic",
+ "product",
+ "wmsTileLayers",
+ "wmtsTileLayers",
+ "yiviAttributeGroups",
+)
+
+
+def _get_mock_request():
+ factory = APIRequestFactory()
+ first_allowed_host = (
+ settings.ALLOWED_HOSTS[0] if settings.ALLOWED_HOSTS else "testserver"
+ )
+ server_name = first_allowed_host if first_allowed_host != "*" else "testserver"
+ request = factory.get("/", SERVER_NAME=server_name)
+ request.is_mock_request = True # pyright: ignore[reportAttributeAccessIssue]
+ return request
+
+
+@transaction.atomic
+def import_form(
+ import_file, import_options: FormImportOptions, existing_form_instance=None
+) -> Form | None:
+ import_data = {}
+ with zipfile.ZipFile(import_file, "r") as zip_file:
+ for resource in EXPECTED_RESOURCES:
+ if f"{resource}.json" in zip_file.namelist():
+ import_data[resource] = zip_file.read(f"{resource}.json").decode()
+
+ return import_form_data(import_data, import_options, existing_form_instance)
+
+
+@transaction.atomic
+@override(language=settings.LANGUAGE_CODE)
+def import_form_data(
+ import_data: dict,
+ import_options: FormImportOptions,
+ existing_form_instance: Form | None = None,
+) -> Form | None:
+ uuid_mapping = {}
+ form_definition_matcher = FormDefinitionMatcher()
+
+ request = _get_mock_request()
+ created_form: Form | None = None
+
+ # Import additional data
+ import_additional_form_configuration_data(
+ resources=import_data,
+ import_options=import_options,
+ uuid_mapping=uuid_mapping,
+ restore_previous_form_version=existing_form_instance is not None,
+ )
+
+ if (forms_data := import_data.get("forms")) is not None:
+ created_form = _import_form_resource(
+ data=forms_data,
+ uuid_mapping=uuid_mapping,
+ request=request,
+ import_options=import_options,
+ existing_form_instance=existing_form_instance,
+ )
+
+ if (form_definitions_data := import_data.get("formDefinitions")) is not None:
+ form_definitions = _import_form_definition_resources(
+ data=form_definitions_data,
+ uuid_mapping=uuid_mapping,
+ form=created_form,
+ request=request,
+ import_options=import_options,
+ matcher=form_definition_matcher,
+ )
+ move_file_registration_options(created_form, form_definitions)
+
+ if (form_steps_data := import_data.get("formSteps")) is not None:
+ _import_form_step_resources(
+ data=form_steps_data,
+ uuid_mapping=uuid_mapping,
+ form=created_form,
+ request=request,
+ import_options=import_options,
+ )
+
+ if (form_variables_data := import_data.get("formVariables")) is not None:
+ _import_form_variable_resources(
+ data=form_variables_data,
+ uuid_mapping=uuid_mapping,
+ form=created_form,
+ request=request,
+ import_options=import_options,
+ )
+
+ if (form_logic_data := import_data.get("formLogic")) is not None:
+ _import_form_logic_resources(
+ data=form_logic_data,
+ uuid_mapping=uuid_mapping,
+ form=created_form,
+ request=request,
+ import_options=import_options,
+ )
+
+ return created_form
+
+
+def _import_form_resource(
+ data: dict,
+ uuid_mapping: dict[str, str],
+ request: Request,
+ import_options: FormImportOptions,
+ existing_form_instance: Form | None,
+) -> Form | None:
+ imported_form = None
+
+ for old, new in uuid_mapping.items():
+ data = data.replace(old, new)
+
+ for entry in json.loads(data):
+ old_uuid = entry.get("uuid")
+
+ deserialized = FormImportSerializer(
+ data=entry,
+ context={
+ "request": request,
+ "is_import": True,
+ "import_options": import_options,
+ },
+ instance=existing_form_instance,
+ )
+
+ try:
+ deserialized.is_valid(raise_exception=True)
+ imported_form = deserialized.save()
+
+ if hasattr(deserialized.instance, "uuid") and "uuid" in entry:
+ uuid_mapping[old_uuid] = str(deserialized.instance.uuid)
+ except ValidationError as e:
+ raise e
+
+ return imported_form
+
+
+def _import_form_definition_resources(
+ data: dict,
+ uuid_mapping: dict[str, str],
+ form: Form,
+ request: Request,
+ import_options: FormImportOptions,
+ matcher: FormDefinitionMatcher,
+) -> list[FormDefinition]:
+ form_definitions: list[FormDefinition] = []
+
+ for old, new in uuid_mapping.items():
+ data = data.replace(old, new)
+
+ for entry in json.loads(data):
+ old_uuid = entry.get("uuid")
+
+ instance: FormDefinition | None = None
+ if import_options.reuse_form_definitions:
+ instance = matcher.find(entry.get("configuration"))
+
+ deserialized = FormDefinitionImportSerializer(
+ data=entry,
+ context={
+ "request": request,
+ "form": form,
+ "is_import": True,
+ "import_options": import_options,
+ },
+ instance=instance,
+ )
+
+ try:
+ deserialized.is_valid(raise_exception=True)
+ form_definitions.append(deserialized.save())
+
+ if hasattr(deserialized.instance, "uuid") and "uuid" in entry:
+ uuid_mapping[old_uuid] = str(deserialized.instance.uuid)
+ except ValidationError as e:
+ raise e
+
+ return form_definitions
+
+
+def _import_form_step_resources(
+ data: dict,
+ uuid_mapping: dict[str, str],
+ form: Form,
+ request: Request,
+ import_options: FormImportOptions,
+):
+ for old, new in uuid_mapping.items():
+ data = data.replace(old, new)
+
+ for entry in json.loads(data):
+ old_uuid = entry.get("uuid")
+
+ deserialized = FormStepImportSerializer(
+ data=entry,
+ context={
+ "request": request,
+ "form": form,
+ "is_import": True,
+ "import_options": import_options,
+ },
+ )
+
+ try:
+ deserialized.is_valid(raise_exception=True)
+ deserialized.save()
+
+ if hasattr(deserialized.instance, "uuid") and "uuid" in entry:
+ uuid_mapping[old_uuid] = str(deserialized.instance.uuid)
+ except ValidationError as e:
+ raise e
+
+
+def _import_form_variable_resources(
+ data: dict,
+ uuid_mapping: dict[str, str],
+ form: Form,
+ request: Request,
+ import_options: FormImportOptions,
+):
+ for old, new in uuid_mapping.items():
+ data = data.replace(old, new)
+
+ for entry in json.loads(data):
+ old_uuid = entry.get("uuid")
+
+ deserialized = FormVariableImportSerializer(
+ data=entry,
+ context={
+ "request": request,
+ "form": form,
+ "is_import": True,
+ "import_options": import_options,
+ "forms": ({str(form.uuid): form}),
+ "form_definitions": {
+ str(fd.uuid): fd
+ for fd in FormDefinition.objects.filter(formstep__form=form)
+ },
+ },
+ )
+
+ try:
+ deserialized.is_valid(raise_exception=True)
+ deserialized.save()
+
+ if hasattr(deserialized.instance, "uuid") and "uuid" in entry:
+ uuid_mapping[old_uuid] = str(deserialized.instance.uuid)
+ except ValidationError as e:
+ raise e
+
+
+def _import_form_logic_resources(
+ data: dict,
+ uuid_mapping: dict[str, str],
+ form: Form,
+ request: Request,
+ import_options: FormImportOptions,
+):
+ for old, new in uuid_mapping.items():
+ data = data.replace(old, new)
+
+ for entry in json.loads(data):
+ old_uuid = entry.get("uuid")
+
+ deserialized = FormLogicImportSerializer(
+ data=entry,
+ context={
+ "request": request,
+ "form": form,
+ "is_import": True,
+ "import_options": import_options,
+ "forms": ({str(form.uuid): form}),
+ "form_definitions": {
+ str(fd.uuid): fd
+ for fd in FormDefinition.objects.filter(formstep__form=form)
+ },
+ "form_variables": FormVariableWrapper(form),
+ "form_steps": {
+ form_step.uuid: form_step
+ for form_step in form.formstep_set.all().order_by("order")
+ },
+ },
+ )
+
+ try:
+ deserialized.is_valid(raise_exception=True)
+ deserialized.save()
+
+ if hasattr(deserialized.instance, "uuid") and "uuid" in entry:
+ uuid_mapping[old_uuid] = str(deserialized.instance.uuid)
+ except ValidationError as e:
+ raise e
+
+
+class FileComponentOptions(TypedDict, total=False):
+ key: Required[str]
+ document_type_description: str
+ organization_rsin: str
+ confidentiality_level: str
+ title: str
+
+
+# Original commit 2d1ef3cbaecd42350470864a1dbb9a134868732c
+def move_file_registration_options(
+ form: Form, form_definitions: Collection[FormDefinition]
+):
+ relevant_backends = [
+ backend
+ for backend in form.registration_backends.all()
+ if backend.backend
+ in (
+ OBJECTS_API_PLUGIN_IDENTIFIER,
+ STUF_ZDS_PLUGIN_IDENTIFIER,
+ ZGW_APIS_PLUGIN_IDENTIFIER,
+ )
+ ]
+ if not relevant_backends:
+ return
+
+ # collect all file components, including the ones inside edit grids
+ file_component_options: dict[str, FileComponentOptions] = {}
+ for fd in form_definitions:
+ for component in fd.configuration_wrapper:
+ if component["type"] != "file":
+ continue
+ if not (registration := component.get("registration")):
+ continue
+ opts: FileComponentOptions = {"key": component["key"]}
+
+ # NOTE: we ignore the catalogue information - the backend-level catalogue
+ # option is used and this is validate at the serializer level
+ document_type_description = (registration.get("documentType") or {}).get(
+ "description"
+ )
+ organization_rsin = registration.get("bronorganisatie")
+ confidentiality_level = registration.get("docVertrouwelijkheidaanduiding")
+ title = registration.get("titel")
+
+ if document_type_description:
+ opts["document_type_description"] = document_type_description
+ if organization_rsin:
+ opts["organization_rsin"] = organization_rsin
+ if confidentiality_level:
+ opts["confidentiality_level"] = confidentiality_level
+ if title:
+ opts["title"] = title
+
+ if len(opts.keys()) != 1:
+ file_component_options[component["key"]] = opts
+
+ if not file_component_options:
+ return
+
+ files = list(file_component_options.values())
+
+ def _file_for_stuf_zds(opts: FileComponentOptions):
+ if title := opts.get("title"):
+ return {"key": opts["key"], "title": title}
+ return None
+
+ files_for_stuf_zds = [o for opts in files if (o := _file_for_stuf_zds(opts))]
+
+ for backend in relevant_backends:
+ options = backend.options
+ if "files" in options:
+ continue
+
+ plugin_id = backend.backend
+ if plugin_id in (OBJECTS_API_PLUGIN_IDENTIFIER, ZGW_APIS_PLUGIN_IDENTIFIER):
+ options["files"] = files
+ elif plugin_id == STUF_ZDS_PLUGIN_IDENTIFIER:
+ options["files"] = files_for_stuf_zds
+ else: # pragma: no cover
+ raise ValueError(f"Unknown registration plugin '{plugin_id}'.")
+
+ # Persist the changes made to the registration backend
+ backend.save()
diff --git a/src/openforms/forms/import_export/matchers/__init__.py b/src/openforms/forms/import_export/matchers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/openforms/forms/import_export/matchers/form_definition.py b/src/openforms/forms/import_export/matchers/form_definition.py
new file mode 100644
index 0000000000..8ab071614a
--- /dev/null
+++ b/src/openforms/forms/import_export/matchers/form_definition.py
@@ -0,0 +1,57 @@
+import hashlib
+import json
+
+from openforms.forms.models import FormDefinition
+from openforms.typing import JSONObject, JSONValue
+
+
+def normalize_configuration(value: JSONValue) -> JSONValue:
+ """
+ Normalize the form configuration by removing the "id" field from all components.
+ """
+ if isinstance(value, dict):
+ return {
+ key: normalize_configuration(child)
+ for key, child in value.items()
+ if key != "id"
+ }
+
+ if isinstance(value, list):
+ return [normalize_configuration(child) for child in value]
+
+ return value
+
+
+def configuration_fingerprint(configuration: JSONObject) -> str:
+ """
+ Turn form definition configuration into a fingerprint.
+ """
+ normalized = normalize_configuration(configuration)
+
+ serialized = json.dumps(
+ normalized,
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+
+
+class FormDefinitionMatcher:
+ def __init__(self):
+ self._instances_by_configuration = self._build_lookup()
+
+ def _build_lookup(self) -> dict[str, list[FormDefinition]]:
+ result: dict[str, list[FormDefinition]] = {}
+
+ for instance in (fd for fd in FormDefinition.objects.all() if fd.is_reusable):
+ key = configuration_fingerprint(instance.configuration)
+ result.setdefault(key, []).append(instance)
+
+ return result
+
+ def find(self, configuration: JSONObject) -> FormDefinition | None:
+ expected_fingerprint = configuration_fingerprint(configuration)
+ matches = self._instances_by_configuration.get(expected_fingerprint, None)
+
+ return matches[0] if matches else None
diff --git a/src/openforms/forms/import_export/resources/base.py b/src/openforms/forms/import_export/resources/base.py
index 310176aedb..c7afaf2739 100644
--- a/src/openforms/forms/import_export/resources/base.py
+++ b/src/openforms/forms/import_export/resources/base.py
@@ -1,10 +1,105 @@
from import_export.resources import ModelResource
from openforms.forms.models import Form
+from openforms.typing import JSONObject
class BaseResource(ModelResource):
+ """
+ The base resource for all import/export resources.
+
+ Attributes:
+ deep_comparison_fields:
+ The fields to use for deep comparison when searching for an existing
+ instance. An instance will have to match all these fields to be considered
+ an exact match.
+ identifier_field:
+ The field that uniquely identifies the instance. This field will be assigned
+ a new identifier value when the identifier in the import-data is already in
+ use, and we want to create a new instance.
+ force_deep_compare:
+ Whether to force a deep comparison when searching for an existing instance.
+ If False, the default behavior is to use the import_id_fields to do a first
+ lookup, and if that fails, fall back to a deep comparison.
+ """
+
+ deep_comparison_fields = ()
+ identifier_field: str
+ force_deep_compare = False
+
+ def __init__(self, force_deep_compare: bool = False, *args, **kwargs):
+ self.force_deep_compare = force_deep_compare
+ super().__init__(*args, **kwargs)
+
def export_for_form(self, form: Form):
raise NotImplementedError(
f"{self.__class__.__name__} must implement export_for_form()"
)
+
+ def generate_identifier(self, row: JSONObject):
+ """
+ A method to generate a unique identifier used when creating a new instance.
+ """
+ raise NotImplementedError(
+ f"{self.__class__.__name__} must implement generate_identifier()"
+ )
+
+ def get_or_init_instance(self, instance_loader, row) -> tuple[JSONObject, bool]:
+ """
+ Search for an existing AttributeGroup with matching attributes.
+
+ The default get_or_init_instance uses the import_id_fields. This does a lookup
+ using the resource unique identifier.
+
+ If the identifier lookup doesn't find an existing instance, we do a deep
+ comparison and return the first match.
+
+ Finally, if no match is found, we create a new instance.
+
+ The return value is a tuple of (instance, is_new).
+ """
+ if not self.force_deep_compare:
+ instance = super().get_instance(instance_loader, row)
+
+ # Return the existing instance, found using the import_id_fields.
+ if instance is not None:
+ row["_matched_existing_instance"] = True
+ return instance, False
+
+ # Collect the parameters for the deep comparison.
+ params = {}
+ for key in self.deep_comparison_fields:
+ params[key] = self.fields[key].clean(row)
+
+ # Perform the deep comparison and return the first match.
+ if (
+ params
+ and (new_found := self.get_queryset().filter(**params).first()) is not None
+ ):
+ row["_matched_existing_instance"] = True
+ return new_found, False
+
+ # No match found, return a new instance.
+ new_instance = super().init_instance(row)
+ new_instance._generated_new_identifier = True
+ return new_instance, True
+
+ def import_field(self, field, instance, row, is_m2m=False, **kwargs) -> None:
+ if field.attribute == self.identifier_field and getattr(
+ instance, "_generated_new_identifier", False
+ ):
+ # Set the identifier field to a new value, generated by the subclass.
+ setattr(instance, field.attribute, self.generate_identifier(row))
+ return
+
+ # Import the field value normally.
+ super().import_field(field, instance, row, is_m2m=is_m2m, **kwargs)
+
+ def skip_row(self, instance, original, row, import_validation_errors=None):
+ """
+ When importing an existing instance, we don't update it.
+ """
+ if row.get("_matched_existing_instance"):
+ return True
+
+ return super().skip_row(instance, original, row, import_validation_errors)
diff --git a/src/openforms/forms/import_export/resources/product.py b/src/openforms/forms/import_export/resources/product.py
index 09dfe1fffc..36339be93f 100644
--- a/src/openforms/forms/import_export/resources/product.py
+++ b/src/openforms/forms/import_export/resources/product.py
@@ -1,3 +1,5 @@
+from uuid import uuid4
+
from openforms.forms.models import Form
from openforms.products.models import Product
@@ -5,9 +7,18 @@
class ProductResource(BaseResource):
+ deep_comparison_fields = ("name", "price", "information")
+ identifier_field = "uuid"
+
class Meta:
model = Product
+ import_id_fields = ("uuid",)
fields = ("uuid", "name", "price", "information")
+ store_instance = True
+ store_row_values = True
def export_for_form(self, form: Form):
return self.export(queryset=[form.product] if form.product is not None else [])
+
+ def generate_identifier(self, row):
+ return uuid4()
diff --git a/src/openforms/forms/import_export/resources/wms_tile_layer.py b/src/openforms/forms/import_export/resources/wms_tile_layer.py
index cb122dd9e7..74ddcde3f6 100644
--- a/src/openforms/forms/import_export/resources/wms_tile_layer.py
+++ b/src/openforms/forms/import_export/resources/wms_tile_layer.py
@@ -1,3 +1,5 @@
+from uuid import uuid4
+
from openforms.config.models import MapWMSTileLayer
from openforms.forms.models import Form
@@ -5,9 +7,15 @@
class WMSTileLayerResource(BaseResource):
+ deep_comparison_fields = ("name", "url")
+ identifier_field = "uuid"
+
class Meta:
model = MapWMSTileLayer
+ import_id_fields = ("uuid",)
fields = ("uuid", "name", "url")
+ store_instance = True
+ store_row_values = True
def export_for_form(self, form: Form):
wms_tile_layers = []
@@ -26,3 +34,6 @@ def export_for_form(self, form: Form):
return self.export(
queryset=MapWMSTileLayer.objects.filter(uuid__in=list(set(wms_tile_layers)))
)
+
+ def generate_identifier(self, row):
+ return uuid4()
diff --git a/src/openforms/forms/import_export/resources/wmts_tile_layer.py b/src/openforms/forms/import_export/resources/wmts_tile_layer.py
index 71cbc2c7aa..d183e26656 100644
--- a/src/openforms/forms/import_export/resources/wmts_tile_layer.py
+++ b/src/openforms/forms/import_export/resources/wmts_tile_layer.py
@@ -1,3 +1,5 @@
+import re
+
from openforms.config.models import MapTileLayer
from openforms.forms.models import Form
@@ -5,9 +7,15 @@
class WMTSTileLayerResource(BaseResource):
+ deep_comparison_fields = ("label", "url")
+ identifier_field = "identifier"
+
class Meta:
model = MapTileLayer
+ import_id_fields = ("identifier",)
fields = ("identifier", "label", "url")
+ store_instance = True
+ store_row_values = True
def export_for_form(self, form: Form):
wmts_tile_layers = []
@@ -24,3 +32,21 @@ def export_for_form(self, form: Form):
identifier__in=list(set(wmts_tile_layers))
)
)
+
+ def generate_identifier(self, row):
+ current_identifier = row["identifier"]
+
+ # In case the identifier already has a numeric suffix, use that as the starting
+ # point.
+ match = re.match(r"^(.*?)(\d+)?$", current_identifier)
+ identifier_base = match.group(1)
+ identifier_index = int(match.group(2)) if match.group(2) is not None else 0
+
+ new_identifier = current_identifier
+ existing_identifiers = MapTileLayer.objects.values_list("identifier", flat=True)
+
+ while new_identifier in existing_identifiers:
+ identifier_index += 1
+ new_identifier = "".join((identifier_base, str(identifier_index)))
+
+ return new_identifier
diff --git a/src/openforms/forms/import_export/resources/yivi_attribute_group.py b/src/openforms/forms/import_export/resources/yivi_attribute_group.py
index db2eb28558..45086f3d93 100644
--- a/src/openforms/forms/import_export/resources/yivi_attribute_group.py
+++ b/src/openforms/forms/import_export/resources/yivi_attribute_group.py
@@ -1,3 +1,5 @@
+from uuid import uuid4
+
from openforms.authentication.contrib.yivi_oidc.models import AttributeGroup
from openforms.forms.models import Form
@@ -5,9 +7,15 @@
class YiviAttributeGroupResource(BaseResource):
+ deep_comparison_fields = ("name", "description", "attributes")
+ identifier_field = "uuid"
+
class Meta:
model = AttributeGroup
+ import_id_fields = ("uuid",)
fields = ("uuid", "name", "description", "attributes")
+ store_instance = True
+ store_row_values = True
def export_for_form(self, form: Form):
yivi_auth_backend = form.auth_backends.all().filter(backend="yivi_oidc").first()
@@ -22,3 +30,6 @@ def export_for_form(self, form: Form):
uuid__in=yivi_auth_backend.options["additional_attributes_groups"]
)
)
+
+ def generate_identifier(self, row):
+ return uuid4()
diff --git a/src/openforms/forms/import_export/serializers/__init__.py b/src/openforms/forms/import_export/serializers/__init__.py
index 663b367518..c5d903cf98 100644
--- a/src/openforms/forms/import_export/serializers/__init__.py
+++ b/src/openforms/forms/import_export/serializers/__init__.py
@@ -1,13 +1,21 @@
-from .form import FormExportSerializer
-from .form_definition import FormDefinitionExportSerializer
-from .form_logic import FormLogicExportSerializer
-from .form_step import FormStepExportSerializer
-from .form_variable import FormVariableExportSerializer
+from .form import FormExportSerializer, FormImportSerializer
+from .form_definition import (
+ FormDefinitionExportSerializer,
+ FormDefinitionImportSerializer,
+)
+from .form_logic import FormLogicExportSerializer, FormLogicImportSerializer
+from .form_step import FormStepExportSerializer, FormStepImportSerializer
+from .form_variable import FormVariableExportSerializer, FormVariableImportSerializer
__all__ = [
"FormExportSerializer",
+ "FormImportSerializer",
"FormDefinitionExportSerializer",
+ "FormDefinitionImportSerializer",
"FormLogicExportSerializer",
+ "FormLogicImportSerializer",
"FormStepExportSerializer",
+ "FormStepImportSerializer",
"FormVariableExportSerializer",
+ "FormVariableImportSerializer",
]
diff --git a/src/openforms/forms/import_export/serializers/base.py b/src/openforms/forms/import_export/serializers/base.py
index 2088e2497a..e899547633 100644
--- a/src/openforms/forms/import_export/serializers/base.py
+++ b/src/openforms/forms/import_export/serializers/base.py
@@ -1,5 +1,6 @@
from collections.abc import Sequence
from typing import ClassVar
+from uuid import uuid4
from rest_framework import serializers
@@ -7,6 +8,7 @@
AdditionalFormConfigurationCleanup,
FormConfigurationCleanup,
FormExportOptions,
+ FormImportOptions,
)
from openforms.typing import JSONObject
@@ -85,3 +87,62 @@ def remove_excluded_additional_form_configuration(
def get_export_options(self) -> FormExportOptions | None:
return self.context.get("export_options", None)
+
+
+class BaseImportSerializer(serializers.Serializer):
+ excluded_form_configuration_removal: list[FormConfigurationCleanup] = ()
+ excluded_additional_form_configuration_removal: list[
+ AdditionalFormConfigurationCleanup
+ ] = ()
+
+ def to_internal_value(self, instance):
+ value = instance.copy()
+
+ # When importing an existing instance, we should not overwrite the uuid
+ if not self.instance:
+ value = self.set_new_uuid(value)
+
+ value = self.apply_backwards_compatibility(value)
+
+ value = self.remove_excluded_form_configuration(value)
+ value = self.remove_excluded_additional_form_configuration(value)
+
+ return super().to_internal_value(value)
+
+ def set_new_uuid(self, value: JSONObject) -> JSONObject:
+ value["uuid"] = uuid4()
+ return value
+
+ def apply_backwards_compatibility(self, value: JSONObject) -> JSONObject:
+ return value
+
+ def remove_excluded_form_configuration(self, value: JSONObject) -> JSONObject:
+ selected_options = (
+ set(import_options.form_configuration)
+ if (import_options := self.get_import_options()) is not None
+ else []
+ )
+
+ for config in self.excluded_form_configuration_removal:
+ if config.option not in selected_options:
+ config.cleanup(value)
+
+ return value
+
+ def remove_excluded_additional_form_configuration(
+ self, value: JSONObject
+ ) -> JSONObject:
+ selected_options = (
+ set(import_options.additional_form_configuration)
+ if (import_options := self.get_import_options()) is not None
+ else []
+ )
+
+ for config in self.excluded_additional_form_configuration_removal:
+ if config.option not in selected_options:
+ config.cleanup(value)
+
+ return value
+
+ def get_import_options(self) -> FormImportOptions | None:
+ return self.context.get("import_options", None)
diff --git a/src/openforms/forms/import_export/serializers/form.py b/src/openforms/forms/import_export/serializers/form.py
index 5cda8fd231..2d00964289 100644
--- a/src/openforms/forms/import_export/serializers/form.py
+++ b/src/openforms/forms/import_export/serializers/form.py
@@ -1,14 +1,26 @@
+import random
+import string
+
+from django.urls import reverse
+
+from openforms.config.models import Theme
+from openforms.emails.utils import sanitize_content
from openforms.forms.api.serializers import FormSerializer
-from openforms.forms.api.serializers.form import FormRegistrationBackendSerializer
+from openforms.forms.api.serializers.form import (
+ FormAuthenticationBackendSerializer,
+ FormRegistrationBackendSerializer,
+)
+from openforms.forms.constants import FormTypeChoices
from openforms.forms.import_export.typing import (
AdditionalFormConfigurationCleanup,
AdditionalFormConfigurationOptions,
FormConfigurationCleanup,
FormConfigurationOptions,
)
+from openforms.forms.models import Category, Form
from openforms.typing import JSONObject
-from .base import BaseExportSerializer
+from .base import BaseExportSerializer, BaseImportSerializer
def clear_product(representation: JSONObject):
@@ -152,3 +164,166 @@ def get_fields(self):
if "payment_options" in fields:
del fields["payment_options"]
return fields
+
+
+class FormImportSerializer(FormSerializer, BaseImportSerializer):
+ excluded_additional_form_configuration_removal = (
+ AdditionalFormConfigurationCleanup(
+ option=AdditionalFormConfigurationOptions.product,
+ cleanup=clear_product,
+ ),
+ AdditionalFormConfigurationCleanup(
+ option=AdditionalFormConfigurationOptions.yivi_attribute_groups,
+ cleanup=clear_yivi_attribute_groups,
+ ),
+ )
+ excluded_form_configuration_removal = (
+ FormConfigurationCleanup(
+ option=FormConfigurationOptions.registration_backends,
+ cleanup=exclude_registration_backends,
+ ),
+ FormConfigurationCleanup(
+ option=FormConfigurationOptions.payment_backend,
+ cleanup=exclude_payment_backend,
+ ),
+ FormConfigurationCleanup(
+ option=FormConfigurationOptions.auth_backends,
+ cleanup=exclude_auth_backends,
+ ),
+ )
+
+ def to_internal_value(self, instance):
+ value = instance.copy()
+
+ value = self.set_theme(value)
+ value = self.set_category(value)
+
+ # We remove all unknown domains from the email templates
+ value = self.sanitize_email_templates(value)
+
+ # When importing a form, it should be non-active by default
+ value["active"] = False
+
+ # Make sure the slug is unique
+ if Form.objects.filter(slug=value.get("slug")).first() is not None:
+ value["slug"] = (
+ f"{value['slug']}-{''.join(random.choices(string.hexdigits, k=6))}"
+ )
+
+ return super().to_internal_value(value)
+
+ def set_theme(self, value: JSONObject) -> JSONObject:
+ if (import_options := self.get_import_options()) is not None and (
+ theme := Theme.objects.filter(uuid=import_options.theme).first()
+ ):
+ theme_url = reverse("api:themes-detail", args=[theme.uuid])
+ value["theme"] = theme_url
+ else:
+ value["theme"] = None
+
+ return value
+
+ def set_category(self, value: JSONObject) -> JSONObject:
+ if (import_options := self.get_import_options()) is not None and (
+ category := Category.objects.filter(uuid=import_options.category).first()
+ ):
+ category_url = reverse("api:categories-detail", args=[category.uuid])
+ value["category"] = category_url
+ else:
+ value["category"] = None
+
+ return value
+
+ def apply_backwards_compatibility(self, value: JSONObject) -> JSONObject:
+ # forms before v4.0 do not have the type field so in case we import an
+ # old appointment form we have to make sure that the form has the right
+ # type configured (by default is regular)
+ # Original commit d8b1d4ea9d31772f059a388347e8a4688be5d717
+ if appointment_options := value.get("appointment_options"):
+ if appointment_options.get("is_appointment"):
+ value["type"] = FormTypeChoices.appointment
+
+ # In v3.2 the authentication_backends field was replaced with auth_backends. This
+ # converter ensures that pre-v3.2 forms are converted correctly. See #5140
+ # Original commit d08281dad2e426e2655d87f67cefec9b58c5c810
+ if (
+ "authentication_backends" in value
+ or "authentication_backend_options" in value
+ ):
+ # Make sure `auth_backends` exists
+ value["auth_backends"] = value.get("auth_backends", [])
+ auth_backends_map = {}
+
+ # Pre-fill the map with the `auth_backends` values
+ for auth_backend in value["auth_backends"]:
+ auth_backends_map[auth_backend["backend"]] = auth_backend
+
+ # Collect all the backends that should be transformed to `auth_backends`
+ if "authentication_backends" in value:
+ for plugin in value["authentication_backends"]:
+ # Add plugin if it's not already in the map
+ if plugin not in auth_backends_map:
+ auth_backends_map[plugin] = {
+ "backend": plugin,
+ "options": None,
+ }
+
+ if "authentication_backend_options" in value:
+ for plugin, options in value["authentication_backend_options"].items():
+ if plugin not in auth_backends_map:
+ auth_backends_map[plugin] = {
+ "backend": plugin,
+ "options": options,
+ }
+ continue
+
+ if auth_backends_map[plugin]["options"] is None:
+ auth_backends_map[plugin]["options"] = options
+
+ validated_auth_backends = []
+ for config in auth_backends_map.values():
+ validated_auth_backends.append(
+ FormAuthenticationBackendSerializer().validate(config)
+ )
+ value["auth_backends"] = validated_auth_backends
+
+ return value
+
+ def sanitize_email_templates(self, value: JSONObject) -> JSONObject:
+ # Sanitize confirmation email templates
+ if value.get("confirmation_email_template", None) is not None:
+ email_template = value["confirmation_email_template"]
+
+ if email_template.get("content") is not None:
+ email_template["content"] = sanitize_content(email_template["content"])
+
+ if email_template.get("cosign_content") is not None:
+ email_template["cosign_content"] = sanitize_content(
+ email_template["cosign_content"]
+ )
+
+ for translation in email_template.get("translations", {}).values():
+ if translation.get("content") is not None:
+ translation["content"] = sanitize_content(translation["content"])
+
+ if translation.get("cosign_content") is not None:
+ translation["cosign_content"] = sanitize_content(
+ translation["cosign_content"]
+ )
+
+ # Sanitize email registration backend email templates
+ for registration in value.get("registration_backends", []):
+ if registration["backend"] == "email":
+ options = registration["options"]
+
+ if options.get("email_content_template_html") is not None:
+ options["email_content_template_html"] = sanitize_content(
+ options["email_content_template_html"]
+ )
+
+ if options.get("email_content_template_text") is not None:
+ options["email_content_template_text"] = sanitize_content(
+ options["email_content_template_text"]
+ )
+
+ return value
diff --git a/src/openforms/forms/import_export/serializers/form_definition.py b/src/openforms/forms/import_export/serializers/form_definition.py
index a14240a421..536f6cda97 100644
--- a/src/openforms/forms/import_export/serializers/form_definition.py
+++ b/src/openforms/forms/import_export/serializers/form_definition.py
@@ -1,3 +1,6 @@
+import structlog
+
+from openforms.formio.migration_converters import CONVERTERS, DEFINITION_CONVERTERS
from openforms.formio.utils import iter_components
from openforms.forms.api.serializers import FormDefinitionSerializer
from openforms.forms.import_export.typing import (
@@ -9,7 +12,9 @@
from openforms.prefill.constants import IdentifierRoles
from openforms.typing import JSONObject
-from .base import BaseExportSerializer
+from .base import BaseExportSerializer, BaseImportSerializer
+
+logger = structlog.stdlib.get_logger(__name__)
def clear_wms_tile_layers(representation: JSONObject):
@@ -87,3 +92,53 @@ def remove_sensitive_content(self, instance, representation):
component["defaultValue"] = ""
return representation
+
+
+class FormDefinitionImportSerializer(FormDefinitionSerializer, BaseImportSerializer):
+ excluded_additional_form_configuration_removal = (
+ AdditionalFormConfigurationCleanup(
+ option=AdditionalFormConfigurationOptions.wms_tile_layers,
+ cleanup=clear_wms_tile_layers,
+ ),
+ AdditionalFormConfigurationCleanup(
+ option=AdditionalFormConfigurationOptions.wmts_tile_layers,
+ cleanup=clear_wmts_tile_layers,
+ ),
+ )
+ excluded_form_configuration_removal = (
+ FormConfigurationCleanup(
+ option=FormConfigurationOptions.prefill,
+ cleanup=remove_prefill_from_component_configuration,
+ ),
+ )
+
+ def to_internal_value(self, instance):
+ value = instance.copy()
+
+ if configuration := value.get("configuration"):
+ self.apply_component_conversions(configuration)
+ self.apply_definition_conversions(configuration)
+
+ return super().to_internal_value(value)
+
+ def apply_component_conversions(self, configuration: JSONObject):
+ """
+ Apply the known formio component conversions to the entire form definition.
+ """
+ log = logger.bind(action="forms.apply_component_conversions")
+ for component in iter_components(configuration):
+ if not (component_type := component.get("type")): # pragma: no cover
+ continue
+ if not (converters := CONVERTERS.get(component_type)):
+ continue
+ for identifier, apply_converter in converters.items():
+ log.debug(
+ "apply_converter",
+ component_type=component_type,
+ identifier=identifier,
+ )
+ apply_converter(component)
+
+ def apply_definition_conversions(self, configuration: JSONObject):
+ for converter in DEFINITION_CONVERTERS:
+ converter(configuration)
diff --git a/src/openforms/forms/import_export/serializers/form_logic.py b/src/openforms/forms/import_export/serializers/form_logic.py
index 6a20db6bbe..863059dbf4 100644
--- a/src/openforms/forms/import_export/serializers/form_logic.py
+++ b/src/openforms/forms/import_export/serializers/form_logic.py
@@ -1,6 +1,11 @@
from openforms.forms.api.serializers import FormLogicSerializer
+from openforms.forms.constants import LogicActionTypes
+from openforms.forms.disable_next_import_conversion import (
+ add_form_step_uuid_to_disable_next_actions,
+)
+from openforms.typing import JSONObject
-from .base import BaseExportSerializer
+from .base import BaseExportSerializer, BaseImportSerializer
class FormLogicExportSerializer(FormLogicSerializer, BaseExportSerializer):
@@ -35,3 +40,45 @@ def remove_sensitive_content(self, instance, representation):
action["action"]["value"] = ""
return representation
+
+
+class FormLogicImportSerializer(FormLogicSerializer, BaseImportSerializer):
+ def to_internal_value(self, instance):
+ value = instance.copy()
+
+ if "order" not in value:
+ value["order"] = 0
+
+ if "service_fetch_configuration" in value:
+ # The transferring between systems case is very tricky better not import
+ # these, as we don't know where this came from. Services and ids may point to
+ # different things in different OF instances. Even when restoring a form
+ # version, we don't know if the service is the same as it was before.
+ del value["service_fetch_configuration"]
+
+ self.clear_old_service_fetch_config(value)
+
+ return super().to_internal_value(value)
+
+ def clear_old_service_fetch_config(self, rule: dict) -> None:
+ for action in rule["actions"]:
+ if action["action"]["type"] != LogicActionTypes.fetch_from_service:
+ continue
+
+ if "value" not in action["action"] or action["action"]["value"] == "":
+ continue
+
+ # See comment in FormVariableImportSerializer `to_internal_value` where we
+ # check if the variable has a `service_fetch_configuration` attribute.
+ # We can't reliably relate the service fetch configured to an existing configuration.
+ # So we don't add any existing service fetch config to the variables
+ action["action"]["value"] = ""
+
+ def apply_backwards_compatibility(self, value: JSONObject) -> JSONObject:
+ # Preparations for 4.0, ensuring legacy imports with disable-next logic actions
+ # still work. See #6254
+ # Original commit 879751310d0bdf6a21bdd143b54f2a3d3f095023
+ add_form_step_uuid_to_disable_next_actions(
+ value, self.context["form_variables"].variables, self.context["form_steps"]
+ )
+ return value
diff --git a/src/openforms/forms/import_export/serializers/form_step.py b/src/openforms/forms/import_export/serializers/form_step.py
index 9229fc8570..d7934e3e2c 100644
--- a/src/openforms/forms/import_export/serializers/form_step.py
+++ b/src/openforms/forms/import_export/serializers/form_step.py
@@ -1,6 +1,7 @@
from openforms.forms.api.serializers import FormStepSerializer
+from openforms.forms.models import FormVariable
-from .base import BaseExportSerializer
+from .base import BaseExportSerializer, BaseImportSerializer
class FormStepExportSerializer(FormStepSerializer, BaseExportSerializer):
@@ -22,3 +23,13 @@ class FormStepExportSerializer(FormStepSerializer, BaseExportSerializer):
"next_text",
"translations",
)
+
+
+class FormStepImportSerializer(FormStepSerializer, BaseImportSerializer):
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+
+ if (form := self.context.get("form")) is not None:
+ # Once the form steps have been created, we create the component
+ # FormVariables based on the form definition configurations.
+ FormVariable.objects.create_for_form(form)
diff --git a/src/openforms/forms/import_export/serializers/form_variable.py b/src/openforms/forms/import_export/serializers/form_variable.py
index a668d05263..a1755ed01f 100644
--- a/src/openforms/forms/import_export/serializers/form_variable.py
+++ b/src/openforms/forms/import_export/serializers/form_variable.py
@@ -6,7 +6,7 @@
from openforms.prefill.constants import IdentifierRoles
from openforms.typing import JSONObject
-from .base import BaseExportSerializer
+from .base import BaseExportSerializer, BaseImportSerializer
def remove_prefill_from_variable(representation: JSONObject):
@@ -55,3 +55,24 @@ def remove_sensitive_content(self, instance, representation):
return representation
return representation
+
+
+class FormVariableImportSerializer(FormVariableSerializer, BaseImportSerializer):
+ excluded_form_configuration_removal = (
+ FormConfigurationCleanup(
+ option=FormConfigurationOptions.prefill,
+ cleanup=remove_prefill_from_variable,
+ ),
+ )
+
+ def to_internal_value(self, instance):
+ value = instance.copy()
+
+ if "service_fetch_configuration" in value:
+ # The transferring between systems case is very tricky better not import
+ # these, as we don't know where this came from. Services and ids may point to
+ # different things in different OF instances. Even when restoring a form
+ # version, we don't know if the service is the same as it was before.
+ del value["service_fetch_configuration"]
+
+ return super().to_internal_value(value)
diff --git a/src/openforms/forms/import_export/typing.py b/src/openforms/forms/import_export/typing.py
index cf20af6dc3..aac63f88a4 100644
--- a/src/openforms/forms/import_export/typing.py
+++ b/src/openforms/forms/import_export/typing.py
@@ -44,6 +44,32 @@ class FormExportOptions:
)
+class FormImportOptionsData(TypedDict, total=False):
+ form_configuration: list[FormConfigurationOptions]
+ additional_form_configuration: list[AdditionalFormConfigurationOptions]
+ reuse_form_definitions: bool
+ theme: str | None
+ category: str | None
+
+
+@dataclass(slots=True)
+class FormImportOptions:
+ form_configuration: list[FormConfigurationOptions] = field(
+ default_factory=lambda: [
+ FormConfigurationOptions.registration_backends,
+ FormConfigurationOptions.prefill,
+ FormConfigurationOptions.payment_backend,
+ FormConfigurationOptions.auth_backends,
+ ]
+ )
+ additional_form_configuration: list[AdditionalFormConfigurationOptions] = field(
+ default_factory=list
+ )
+ reuse_form_definitions: bool = True
+ theme: str | None = None
+ category: str | None = None
+
+
@dataclass(frozen=True)
class AdditionalFormConfigurationCleanup:
option: AdditionalFormConfigurationOptions
diff --git a/src/openforms/forms/import_export/utils.py b/src/openforms/forms/import_export/utils.py
index a2a6f31f7c..c5ad0adaed 100644
--- a/src/openforms/forms/import_export/utils.py
+++ b/src/openforms/forms/import_export/utils.py
@@ -1,5 +1,8 @@
from dataclasses import dataclass
+import tablib
+from import_export.results import RowResult
+
from openforms.forms.models import Form
from openforms.typing import JSONObject
@@ -10,32 +13,36 @@
WMTSTileLayerResource,
YiviAttributeGroupResource,
)
-from .typing import AdditionalFormConfigurationOptions, FormExportOptions
+from .typing import (
+ AdditionalFormConfigurationOptions,
+ FormExportOptions,
+ FormImportOptions,
+)
@dataclass(frozen=True)
-class ExportResourceConfig:
+class ResourceConfig:
resource: type[BaseResource]
output_name: str
ADDITIONAL_FORM_CONFIGURATION_RESOURCES: dict[
AdditionalFormConfigurationOptions,
- ExportResourceConfig,
+ ResourceConfig,
] = {
- AdditionalFormConfigurationOptions.product: ExportResourceConfig(
+ AdditionalFormConfigurationOptions.product: ResourceConfig(
resource=ProductResource,
output_name="product",
),
- AdditionalFormConfigurationOptions.wms_tile_layers: ExportResourceConfig(
+ AdditionalFormConfigurationOptions.wms_tile_layers: ResourceConfig(
resource=WMSTileLayerResource,
output_name="wmsTileLayers",
),
- AdditionalFormConfigurationOptions.wmts_tile_layers: ExportResourceConfig(
+ AdditionalFormConfigurationOptions.wmts_tile_layers: ResourceConfig(
resource=WMTSTileLayerResource,
output_name="wmtsTileLayers",
),
- AdditionalFormConfigurationOptions.yivi_attribute_groups: ExportResourceConfig(
+ AdditionalFormConfigurationOptions.yivi_attribute_groups: ResourceConfig(
resource=YiviAttributeGroupResource,
output_name="yiviAttributeGroups",
),
@@ -67,3 +74,45 @@ def get_additional_form_configuration_data(
resources[config.output_name] = config.resource().export_for_form(form).json
return resources
+
+
+def import_additional_form_configuration_data(
+ resources: JSONObject,
+ import_options: FormImportOptions,
+ uuid_mapping=dict[str, str],
+ restore_previous_form_version: bool = False,
+):
+ selected_options = set(import_options.additional_form_configuration)
+ unknown_options = selected_options - set(ADDITIONAL_FORM_CONFIGURATION_RESOURCES)
+
+ if unknown_options:
+ raise ValueError(
+ f"Invalid additional form configuration option(s): {unknown_options}"
+ )
+
+ for option, config in ADDITIONAL_FORM_CONFIGURATION_RESOURCES.items():
+ if option in selected_options and config.output_name in resources:
+ dataset = tablib.Dataset().load(resources[config.output_name], "json")
+ results = config.resource(
+ force_deep_compare=restore_previous_form_version
+ ).import_data(dataset)
+
+ for row_result in results:
+ identifier_field = config.resource.identifier_field
+ old_identifier = row_result.row_values.get(identifier_field)
+
+ new_identifier = None
+ match row_result.import_type:
+ case RowResult.IMPORT_TYPE_NEW:
+ new_identifier = getattr(row_result.instance, identifier_field)
+
+ case RowResult.IMPORT_TYPE_SKIP:
+ new_identifier = getattr(row_result.original, identifier_field)
+
+ case _:
+ raise ValueError(
+ f"Invalid import type: {row_result.import_type}"
+ )
+
+ if new_identifier is not None:
+ uuid_mapping[old_identifier] = str(new_identifier)
diff --git a/src/openforms/forms/models/form.py b/src/openforms/forms/models/form.py
index 9d9d97debd..11ca35e3b3 100644
--- a/src/openforms/forms/models/form.py
+++ b/src/openforms/forms/models/form.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
import uuid as _uuid
from collections.abc import Iterator, Mapping
from contextlib import suppress
@@ -32,6 +33,11 @@
from openforms.data_removal.constants import RemovalMethods
from openforms.formio.typing import Component
from openforms.formio.validators import variable_key_validator
+from openforms.forms.import_export.typing import (
+ AdditionalFormConfigurationOptions,
+ FormConfigurationOptions,
+ FormImportOptions,
+)
from openforms.payments.fields import PaymentBackendChoiceField
from openforms.payments.registry import register as payment_register
from openforms.plugins.constants import UNIQUE_ID_MAX_LENGTH
@@ -708,11 +714,40 @@ def iter_components(self, recursive=True) -> Iterator[Component]:
for form_step in self.formstep_set.select_related("form_definition"):
yield from form_step.iter_components(recursive=recursive)
+ @staticmethod
+ def _delete_current_form_configuration(current_form: Form):
+ from . import FormDefinition, FormLogic, FormStep, FormVariable
+
+ form_steps = FormStep.objects.filter(form=current_form)
+ # delete single-use form definitions, they're orphan nodes when deleting the steps
+ fd_ids = list(
+ FormDefinition.objects.filter(
+ is_reusable=False, formstep__in=form_steps
+ ).values_list("id", flat=True)
+ )
+ form_steps.delete()
+ FormDefinition.objects.filter(id__in=fd_ids).delete()
+ FormLogic.objects.filter(form=current_form).delete()
+ FormVariable.objects.filter(form=current_form).delete()
+
+ @staticmethod
+ def _get_uuid_from_resource_url(url: str | None) -> UUID | None:
+ from urllib.parse import urlparse
+
+ from django.urls import resolve
+
+ if url is None:
+ return None
+
+ result = resolve(urlparse(url).path)
+ return result.kwargs.get("uuid")
+
@transaction.atomic
def restore_old_version(
- self, form_version_uuid: str, user: User | None = None
+ self, form_version_uuid: UUID, user: User | None = None
) -> None:
- from ..utils import import_form_data
+ from openforms.forms.import_export.import_form import import_form_data
+
from .form_version import FormVersion
# we use the window function to find the record with its index in _all_
@@ -730,7 +765,46 @@ def restore_old_version(
form_version = form_versions_mapping[form_version_uuid]
old_version_data = form_version.export_blob
- import_form_data(old_version_data, form_version.form)
+ # when restoring a previous version, delete the current form configuration;
+ # it will be replaced with the import data.
+ self._delete_current_form_configuration(form_version.form)
+
+ forms = json.loads(old_version_data["forms"])
+ assert len(forms) == 1, "expected exactly one form in the old version"
+ form_data = forms[0]
+
+ theme_uuid = self._get_uuid_from_resource_url(form_data.get("theme"))
+ category_uuid = self._get_uuid_from_resource_url(form_data.get("category"))
+
+ restored_form = import_form_data(
+ old_version_data,
+ FormImportOptions(
+ form_configuration=[
+ FormConfigurationOptions.registration_backends,
+ FormConfigurationOptions.prefill,
+ FormConfigurationOptions.payment_backend,
+ FormConfigurationOptions.auth_backends,
+ ],
+ additional_form_configuration=[
+ AdditionalFormConfigurationOptions.product,
+ AdditionalFormConfigurationOptions.wms_tile_layers,
+ AdditionalFormConfigurationOptions.wmts_tile_layers,
+ AdditionalFormConfigurationOptions.yivi_attribute_groups,
+ ],
+ reuse_form_definitions=True,
+ theme=theme_uuid,
+ category=category_uuid,
+ ),
+ form_version.form,
+ )
+
+ # The FormImportSerializer sets the 'active' state to False by default. We should
+ # restore it to the state it had before.
+ if (
+ previous_active := form_data.get("active")
+ ) is not None and previous_active is not restored_form.active:
+ restored_form.active = previous_active
+ restored_form.save()
# now create a new FormVersion for this restore as well, tracking those nuances
# in the description.
diff --git a/src/openforms/forms/templates/admin/forms/form/export.html b/src/openforms/forms/templates/admin/forms/form/export.html
index bbc8fda22d..fe6d3910bd 100644
--- a/src/openforms/forms/templates/admin/forms/form/export.html
+++ b/src/openforms/forms/templates/admin/forms/form/export.html
@@ -56,7 +56,16 @@
{% trans 'Export forms' %}
{{ field.errors }}
{{ field.label_tag }}
- {{ field }}
+
+ {% if field|length == 1 %}
+ {{ field }}
+ {% else %}
+