diff --git a/src/openapi.yaml b/src/openapi.yaml index ac2561fa52..87da5022df 100644 --- a/src/openapi.yaml +++ b/src/openapi.yaml @@ -1336,6 +1336,8 @@ paths: - `deactivateOn` - `isDeleted` - `submissionConfirmationTemplate` + - `submissionLimit` + - `submissionCounter` - `askPrivacyConsent` - `askStatementOfTruth` - `submissionsRemovalOptions` @@ -1407,6 +1409,8 @@ paths: - `deactivateOn` - `isDeleted` - `submissionConfirmationTemplate` + - `submissionLimit` + - `submissionCounter` - `askPrivacyConsent` - `askStatementOfTruth` - `submissionsRemovalOptions` @@ -1922,6 +1926,8 @@ paths: - `deactivateOn` - `isDeleted` - `submissionConfirmationTemplate` + - `submissionLimit` + - `submissionCounter` - `askPrivacyConsent` - `askStatementOfTruth` - `submissionsRemovalOptions` @@ -1999,6 +2005,8 @@ paths: - `deactivateOn` - `isDeleted` - `submissionConfirmationTemplate` + - `submissionLimit` + - `submissionCounter` - `askPrivacyConsent` - `askStatementOfTruth` - `submissionsRemovalOptions` @@ -2080,6 +2088,8 @@ paths: - `deactivateOn` - `isDeleted` - `submissionConfirmationTemplate` + - `submissionLimit` + - `submissionCounter` - `askPrivacyConsent` - `askStatementOfTruth` - `submissionsRemovalOptions` @@ -7507,7 +7517,7 @@ components: Note that this schema is used for both non-admin users filling out forms and admin users designing forms. The fields that are only relevant for admin users are: - `internalName`, `registrationBackends`, `authenticationBackendOptions`, `paymentBackend`, `paymentBackendOptions`, `priceVariableKey`, `product`, `category`, `theme`, `activateOn`, `deactivateOn`, `isDeleted`, `submissionConfirmationTemplate`, `askPrivacyConsent`, `askStatementOfTruth`, `submissionsRemovalOptions`, `confirmationEmailTemplate`, `displayMainWebsiteLink`, `includeConfirmationPageContentInPdf`, `translations`, `brpPersonenRequestOptions`. + `internalName`, `registrationBackends`, `authenticationBackendOptions`, `paymentBackend`, `paymentBackendOptions`, `priceVariableKey`, `product`, `category`, `theme`, `activateOn`, `deactivateOn`, `isDeleted`, `submissionConfirmationTemplate`, `submissionLimit`, `submissionCounter`, `askPrivacyConsent`, `askStatementOfTruth`, `submissionsRemovalOptions`, `confirmationEmailTemplate`, `displayMainWebsiteLink`, `includeConfirmationPageContentInPdf`, `translations`, `brpPersonenRequestOptions`. properties: uuid: type: string @@ -7653,6 +7663,25 @@ components: * `yes` - Yes * `no_with_overview` - No (with overview page) * `no_without_overview` - No (without overview page) + submissionLimit: + type: integer + maximum: 2147483647 + minimum: 0 + nullable: true + title: Maximum allowed submissions + description: Maximum number of allowed submissions per form. Leave this + empty if no limit is needed. + submissionCounter: + type: integer + maximum: 2147483647 + minimum: 0 + title: Submissions counter + description: Counter to track how many submissions have been completed for + the specific form. This works in combination with the maximum allowed + submissions per form and can be reset via the frontend. + submissionLimitReached: + type: boolean + readOnly: true suspensionAllowed: type: boolean description: Whether the user is allowed to suspend this form or not. @@ -7768,6 +7797,7 @@ components: - resumeLinkLifetime - slug - steps + - submissionLimitReached - submissionReportDownloadLinkTitle - submissionStatementsConfiguration - url @@ -8014,7 +8044,7 @@ components: Note that this schema is used for both non-admin users filling out forms and admin users designing forms. The fields that are only relevant for admin users are: - `internalName`, `registrationBackends`, `authenticationBackendOptions`, `paymentBackend`, `paymentBackendOptions`, `priceVariableKey`, `product`, `category`, `theme`, `activateOn`, `deactivateOn`, `isDeleted`, `submissionConfirmationTemplate`, `askPrivacyConsent`, `askStatementOfTruth`, `submissionsRemovalOptions`, `confirmationEmailTemplate`, `displayMainWebsiteLink`, `includeConfirmationPageContentInPdf`, `translations`, `brpPersonenRequestOptions`. + `internalName`, `registrationBackends`, `authenticationBackendOptions`, `paymentBackend`, `paymentBackendOptions`, `priceVariableKey`, `product`, `category`, `theme`, `activateOn`, `deactivateOn`, `isDeleted`, `submissionConfirmationTemplate`, `submissionLimit`, `submissionCounter`, `askPrivacyConsent`, `askStatementOfTruth`, `submissionsRemovalOptions`, `confirmationEmailTemplate`, `displayMainWebsiteLink`, `includeConfirmationPageContentInPdf`, `translations`, `brpPersonenRequestOptions`. properties: name: type: string @@ -8185,7 +8215,7 @@ components: Note that this schema is used for both non-admin users filling out forms and admin users designing forms. The fields that are only relevant for admin users are: - `internalName`, `registrationBackends`, `authenticationBackendOptions`, `paymentBackend`, `paymentBackendOptions`, `priceVariableKey`, `product`, `category`, `theme`, `activateOn`, `deactivateOn`, `isDeleted`, `submissionConfirmationTemplate`, `askPrivacyConsent`, `askStatementOfTruth`, `submissionsRemovalOptions`, `confirmationEmailTemplate`, `displayMainWebsiteLink`, `includeConfirmationPageContentInPdf`, `translations`, `brpPersonenRequestOptions`. + `internalName`, `registrationBackends`, `authenticationBackendOptions`, `paymentBackend`, `paymentBackendOptions`, `priceVariableKey`, `product`, `category`, `theme`, `activateOn`, `deactivateOn`, `isDeleted`, `submissionConfirmationTemplate`, `submissionLimit`, `submissionCounter`, `askPrivacyConsent`, `askStatementOfTruth`, `submissionsRemovalOptions`, `confirmationEmailTemplate`, `displayMainWebsiteLink`, `includeConfirmationPageContentInPdf`, `translations`, `brpPersonenRequestOptions`. properties: name: type: string @@ -9112,7 +9142,7 @@ components: Note that this schema is used for both non-admin users filling out forms and admin users designing forms. The fields that are only relevant for admin users are: - `internalName`, `registrationBackends`, `authenticationBackendOptions`, `paymentBackend`, `paymentBackendOptions`, `priceVariableKey`, `product`, `category`, `theme`, `activateOn`, `deactivateOn`, `isDeleted`, `submissionConfirmationTemplate`, `askPrivacyConsent`, `askStatementOfTruth`, `submissionsRemovalOptions`, `confirmationEmailTemplate`, `displayMainWebsiteLink`, `includeConfirmationPageContentInPdf`, `translations`, `brpPersonenRequestOptions`. + `internalName`, `registrationBackends`, `authenticationBackendOptions`, `paymentBackend`, `paymentBackendOptions`, `priceVariableKey`, `product`, `category`, `theme`, `activateOn`, `deactivateOn`, `isDeleted`, `submissionConfirmationTemplate`, `submissionLimit`, `submissionCounter`, `askPrivacyConsent`, `askStatementOfTruth`, `submissionsRemovalOptions`, `confirmationEmailTemplate`, `displayMainWebsiteLink`, `includeConfirmationPageContentInPdf`, `translations`, `brpPersonenRequestOptions`. properties: uuid: type: string @@ -9258,6 +9288,25 @@ components: * `yes` - Yes * `no_with_overview` - No (with overview page) * `no_without_overview` - No (without overview page) + submissionLimit: + type: integer + maximum: 2147483647 + minimum: 0 + nullable: true + title: Maximum allowed submissions + description: Maximum number of allowed submissions per form. Leave this + empty if no limit is needed. + submissionCounter: + type: integer + maximum: 2147483647 + minimum: 0 + title: Submissions counter + description: Counter to track how many submissions have been completed for + the specific form. This works in combination with the maximum allowed + submissions per form and can be reset via the frontend. + submissionLimitReached: + type: boolean + readOnly: true suspensionAllowed: type: boolean description: Whether the user is allowed to suspend this form or not. diff --git a/src/openforms/forms/admin/form.py b/src/openforms/forms/admin/form.py index 3e4f5f815e..bd170f0628 100644 --- a/src/openforms/forms/admin/form.py +++ b/src/openforms/forms/admin/form.py @@ -1,6 +1,6 @@ from django.contrib import admin, messages from django.contrib.admin.templatetags.admin_list import result_headers -from django.db.models import Count +from django.db.models import BooleanField, Case, Count, F, Value, When from django.http.response import HttpResponse, HttpResponseRedirect from django.template.response import TemplateResponse from django.urls import path, reverse @@ -48,6 +48,30 @@ class FormStepInline(OrderedTabularInline): extra = 1 +class FormReachedSubmissionLimitListFilter(admin.SimpleListFilter): + title = _("has reached submission limit") + parameter_name = "submission_limit" + + def lookups(self, request, model_admin): + return [ + ("available", _("Available for submission")), + ("unavailable", _("Unavailable for submission")), + ] + + def queryset(self, request, queryset): + queryset = queryset.annotate( + _submissions_limit_reached=Case( + When(submission_limit__lte=F("submission_counter"), then=Value(True)), + default=Value(False), + output_field=BooleanField(), + ) + ) + if self.value() == "available": + return queryset.filter(_submissions_limit_reached=False) + elif self.value() == "unavailable": + return queryset.filter(_submissions_limit_reached=True) + + class FormDeletedListFilter(admin.ListFilter): title = _("is deleted") parameter_name = "deleted" @@ -112,6 +136,7 @@ class FormAdmin( "active", "maintenance_mode", "translation_enabled", + "submission_limit", "get_authentication_backends_display", "get_payment_backend_display", "get_registration_backend_display", @@ -129,6 +154,7 @@ class FormAdmin( "maintenance_mode", "translation_enabled", FormDeletedListFilter, + FormReachedSubmissionLimitListFilter, ) search_fields = ("uuid", "name", "internal_name", "slug") diff --git a/src/openforms/forms/api/serializers/form.py b/src/openforms/forms/api/serializers/form.py index 71b6d69b45..89682cd9ab 100644 --- a/src/openforms/forms/api/serializers/form.py +++ b/src/openforms/forms/api/serializers/form.py @@ -212,6 +212,7 @@ class FormSerializer(PublicFieldsSerializerMixin, serializers.ModelSerializer): "of type 'checkbox'." ), ) + submission_limit_reached = serializers.SerializerMethodField() brp_personen_request_options = BRPPersonenRequestOptionsSerializer( required=False, allow_null=True ) @@ -257,6 +258,9 @@ class Meta: "introduction_page_content", "explanation_template", "submission_allowed", + "submission_limit", + "submission_counter", + "submission_limit_reached", "suspension_allowed", "ask_privacy_consent", "ask_statement_of_truth", @@ -299,6 +303,7 @@ class Meta: "active", "required_fields_with_asterisk", "submission_allowed", + "submission_limit_reached", "suspension_allowed", "send_confirmation_email", "appointment_options", @@ -513,6 +518,9 @@ def get_cosign_has_link_in_email(self, obj: Form) -> bool: config = GlobalConfiguration.get_solo() return config.cosign_request_template_has_link + def get_submission_limit_reached(self, obj: Form) -> bool: + return obj.submissions_limit_reached + FormSerializer.__doc__ = FormSerializer.__doc__.format( admin_fields=", ".join( diff --git a/src/openforms/forms/migrations/0107_form_submission_counter_form_submission_limit.py b/src/openforms/forms/migrations/0107_form_submission_counter_form_submission_limit.py new file mode 100644 index 0000000000..4353560e3b --- /dev/null +++ b/src/openforms/forms/migrations/0107_form_submission_counter_form_submission_limit.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.17 on 2024-12-12 10:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forms", "0106_convert_price_logic_rules"), + ] + + operations = [ + migrations.AddField( + model_name="form", + name="submission_counter", + field=models.PositiveIntegerField( + default=0, + help_text="Counter to track how many submissions have been completed for the specific form. This works in combination with the maximum allowed submissions per form and can be reset via the frontend.", + verbose_name="submissions counter", + ), + ), + migrations.AddField( + model_name="form", + name="submission_limit", + field=models.PositiveIntegerField( + blank=True, + help_text="Maximum number of allowed submissions per form. Leave this empty if no limit is needed.", + null=True, + verbose_name="maximum allowed submissions", + ), + ), + ] diff --git a/src/openforms/forms/models/form.py b/src/openforms/forms/models/form.py index f08dccb956..5647013db0 100644 --- a/src/openforms/forms/models/form.py +++ b/src/openforms/forms/models/form.py @@ -124,6 +124,23 @@ class Form(models.Model): ) # submission + submission_limit = models.PositiveIntegerField( + _("maximum allowed submissions"), + null=True, + blank=True, + help_text=_( + "Maximum number of allowed submissions per form. Leave this empty if no limit is needed." + ), + ) + submission_counter = models.PositiveIntegerField( + _("submissions counter"), + default=0, + help_text=_( + "Counter to track how many submissions have been completed for the specific form. " + "This works in combination with the maximum allowed submissions per form and can be " + "reset via the frontend." + ), + ) submission_confirmation_template = HTMLField( _("submission confirmation template"), help_text=_( @@ -380,12 +397,28 @@ def __str__(self): @property def is_available(self) -> bool: """ - Soft deleted, deactivated or forms in maintenance mode are not available. + Check if the form is available to start, continue or complete. + + Soft deleted, deactivated, forms in maintenance mode or forms which have reached the + submission limit are not available. """ - if any((self._is_deleted, not self.active, self.maintenance_mode)): + if any( + ( + self._is_deleted, + not self.active, + self.maintenance_mode, + self.submissions_limit_reached, + ) + ): return False return True + @property + def submissions_limit_reached(self) -> bool: + if (limit := self.submission_limit) and limit <= self.submission_counter: + return True + return False + def get_absolute_url(self): return reverse("forms:form-detail", kwargs={"slug": self.slug}) @@ -485,6 +518,7 @@ def copy(self): ) copy.slug = _("{slug}-copy").format(slug=self.slug) copy.product = self.product + copy.submission_counter = 0 # name translations diff --git a/src/openforms/forms/tests/admin/mixins.py b/src/openforms/forms/tests/admin/mixins.py new file mode 100644 index 0000000000..533b500f72 --- /dev/null +++ b/src/openforms/forms/tests/admin/mixins.py @@ -0,0 +1,43 @@ +from django.urls import reverse + +from ...models import Category + + +class FormListAjaxMixin: + def _get_form_changelist(self, query=None, **kwargs): + """ + Utility to mimick the ajax-loading of form tables per category. + """ + query = query or {} + + url = reverse("admin:forms_form_changelist") + # get the server rendered scaffolding + changelist = self.app.get(url, params=query, **kwargs) + + return self._load_async_category_form_lists(changelist, **kwargs) + + def _load_async_category_form_lists(self, response, query=None, **kwargs): + url = reverse("admin:forms_form_changelist") + query = dict(response.request.params) + + # get the ajax call responses + category_ids = [""] + list(Category.objects.values_list("id", flat=True)) + for category_id in category_ids: + ajax_response = self.app.get( + url, params={**query, "_async": 1, "category": category_id}, **kwargs + ) + if not ajax_response.text.strip(): + continue + + pq = response.pyquery("html") + rows = ajax_response.pyquery("tbody") + if not rows: + continue + + # rewrite the response body with the new HTML as if injected by JS + pq("table").append(rows.html()) + response.text = pq.html() + + response._forms_indexed = None + + return response diff --git a/src/openforms/forms/tests/admin/test_filters.py b/src/openforms/forms/tests/admin/test_filters.py new file mode 100644 index 0000000000..6321d38832 --- /dev/null +++ b/src/openforms/forms/tests/admin/test_filters.py @@ -0,0 +1,50 @@ +from django_webtest import WebTest +from maykin_2fa.test import disable_admin_mfa + +from openforms.accounts.tests.factories import SuperUserFactory + +from ..factories import FormFactory +from .mixins import FormListAjaxMixin + + +@disable_admin_mfa() +class FormReachedSubmissionLimitListFilterTests(FormListAjaxMixin, WebTest): + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.user = SuperUserFactory.create() + + def test_visible_forms_when_submission_limit_available(self): + self.app.set_user(user=self.user) + + visible_form = FormFactory.create() + visible_form2 = FormFactory.create(submission_limit=2, submission_counter=1) + not_visible_form = FormFactory.create(submission_limit=2, submission_counter=2) + not_visible_form2 = FormFactory.create(submission_limit=2, submission_counter=3) + + response = self._get_form_changelist( + user=self.user, query={"submission_limit": "available"} + ) + + self.assertContains(response, visible_form.name) + self.assertContains(response, visible_form2.name) + self.assertNotContains(response, not_visible_form.name) + self.assertNotContains(response, not_visible_form2.name) + + def test_visible_forms_when_submission_limit_unavailable(self): + self.app.set_user(user=self.user) + + not_visible_form = FormFactory.create() + not_visible_form2 = FormFactory.create(submission_limit=2, submission_counter=1) + visible_form = FormFactory.create(submission_limit=2, submission_counter=2) + visible_form2 = FormFactory.create(submission_limit=2, submission_counter=3) + + response = self._get_form_changelist( + user=self.user, query={"submission_limit": "unavailable"} + ) + + self.assertContains(response, visible_form.name) + self.assertContains(response, visible_form2.name) + self.assertNotContains(response, not_visible_form.name) + self.assertNotContains(response, not_visible_form2.name) diff --git a/src/openforms/forms/tests/admin/test_form.py b/src/openforms/forms/tests/admin/test_form.py index e87bb4c794..08517af797 100644 --- a/src/openforms/forms/tests/admin/test_form.py +++ b/src/openforms/forms/tests/admin/test_form.py @@ -21,48 +21,9 @@ from ...admin.form import FormAdmin from ...constants import EXPORT_META_KEY -from ...models import Category, Form, FormDefinition, FormStep, FormVariable +from ...models import Form, FormDefinition, FormStep, FormVariable from ...tests.factories import FormDefinitionFactory, FormFactory, FormStepFactory - - -class FormListAjaxMixin: - def _get_form_changelist(self, query=None, **kwargs): - """ - Utility to mimick the ajax-loading of form tables per category. - """ - query = query or {} - - url = reverse("admin:forms_form_changelist") - # get the server rendered scaffolding - changelist = self.app.get(url, params=query, **kwargs) - - return self._load_async_category_form_lists(changelist, **kwargs) - - def _load_async_category_form_lists(self, response, query=None, **kwargs): - url = reverse("admin:forms_form_changelist") - query = dict(response.request.params) - - # get the ajax call responses - category_ids = [""] + list(Category.objects.values_list("id", flat=True)) - for category_id in category_ids: - ajax_response = self.app.get( - url, params={**query, "_async": 1, "category": category_id}, **kwargs - ) - if not ajax_response.text.strip(): - continue - - pq = response.pyquery("html") - rows = ajax_response.pyquery("tbody") - if not rows: - continue - - # rewrite the response body with the new HTML as if injected by JS - pq("table").append(rows.html()) - response.text = pq.html() - - response._forms_indexed = None - - return response +from .mixins import FormListAjaxMixin @disable_admin_mfa() diff --git a/src/openforms/forms/tests/test_form_admin.py b/src/openforms/forms/tests/test_form_admin.py index ca6efc238d..e13a6f4ef3 100644 --- a/src/openforms/forms/tests/test_form_admin.py +++ b/src/openforms/forms/tests/test_form_admin.py @@ -10,7 +10,7 @@ from ...registrations.base import BasePlugin from ..models import Form -from .admin.test_form import FormListAjaxMixin +from .admin.mixins import FormListAjaxMixin from .factories import FormFactory register = Registry() diff --git a/src/openforms/forms/tests/test_models.py b/src/openforms/forms/tests/test_models.py index b112a7d2fe..1c26253483 100644 --- a/src/openforms/forms/tests/test_models.py +++ b/src/openforms/forms/tests/test_models.py @@ -53,6 +53,18 @@ def test_registration_backend_display_multiple_backends(self): form.get_registration_backend_display(), "Backend #1, Backend #2" ) + def test_form_is_unavailable_when_limit_reached(self): + form: Form = FormFactory.create(submission_limit=2, submission_counter=2) + self.assertFalse(form.is_available) + + def test_form_is_unavailable_when_limit_exceeded(self): + form: Form = FormFactory.create(submission_limit=2, submission_counter=3) + self.assertFalse(form.is_available) + + def test_form_is_available_when_limit_not_reached(self): + form: Form = FormFactory.create(submission_limit=2, submission_counter=1) + self.assertTrue(form.is_available) + @override_settings(LANGUAGE_CODE="en") def test_registration_backend_display_marks_misconfigs(self): form: Form = FormFactory.create() diff --git a/src/openforms/forms/tests/test_serializers.py b/src/openforms/forms/tests/test_serializers.py index d77f864a4b..c7ef8d854e 100644 --- a/src/openforms/forms/tests/test_serializers.py +++ b/src/openforms/forms/tests/test_serializers.py @@ -333,3 +333,22 @@ def test_patching_registrations_with_a_booboo(self): self.assertEqual(backend2.name, "#2") self.assertEqual(backend2.backend, "email") self.assertEqual(backend2.options["to_emails"], ["me@example.com"]) + + def test_submission_limit_method_field(self): + context = {"request": None} + + with self.subTest("submission_limit equal to submission_counter"): + form = FormFactory.create(submission_limit=2, submission_counter=2) + data = FormSerializer(instance=form, context=context).data + + self.assertTrue(data["submission_limit_reached"]) + with self.subTest("submission_max_allowed bigger than submission_counter"): + form = FormFactory.create(submission_limit=2, submission_counter=1) + data = FormSerializer(instance=form, context=context).data + + self.assertFalse(data["submission_limit_reached"]) + with self.subTest("submission_max_allowed smaller than submission_counter"): + form = FormFactory.create(submission_limit=1, submission_counter=2) + data = FormSerializer(instance=form, context=context).data + + self.assertTrue(data["submission_limit_reached"]) diff --git a/src/openforms/forms/tests/test_submission_counter.py b/src/openforms/forms/tests/test_submission_counter.py new file mode 100644 index 0000000000..a29e082d5b --- /dev/null +++ b/src/openforms/forms/tests/test_submission_counter.py @@ -0,0 +1,32 @@ +from rest_framework.reverse import reverse +from rest_framework.test import APITestCase + +from openforms.forms.tests.factories import FormFactory +from openforms.submissions.tests.factories import SubmissionFactory +from openforms.submissions.tests.mixins import SubmissionsMixin + + +class SubmissionCounterTests(SubmissionsMixin, APITestCase): + def test_form_submission_counter_is_incremented(self): + form = FormFactory.create(submission_limit=1, submission_counter=0) + submission = SubmissionFactory.create(form=form) + self._add_submission_to_session(submission) + + endpoint = reverse("api:submission-complete", kwargs={"uuid": submission.uuid}) + self.client.post(endpoint, {"privacy_policy_accepted": True}) + + form.refresh_from_db() + + self.assertEqual(form.submission_counter, 1) + + def test_form_submission_counter_is_not_incremented(self): + form = FormFactory.create() + submission = SubmissionFactory.create(form=form) + self._add_submission_to_session(submission) + + endpoint = reverse("api:submission-complete", kwargs={"uuid": submission.uuid}) + self.client.post(endpoint, {"privacy_policy_accepted": True}) + + form.refresh_from_db() + + self.assertEqual(form.submission_counter, 0) diff --git a/src/openforms/forms/utils.py b/src/openforms/forms/utils.py index 121f505690..5b32380bcb 100644 --- a/src/openforms/forms/utils.py +++ b/src/openforms/forms/utils.py @@ -73,6 +73,9 @@ def form_to_json(form_id: int) -> dict: # Ignore products in the export form.product = None + # Reset the submission counter + form.submission_counter = 0 + form_steps = FormStep.objects.filter(form__pk=form_id).select_related( "form_definition" ) diff --git a/src/openforms/js/compiled-lang/en.json b/src/openforms/js/compiled-lang/en.json index dd8f7fbfa0..be2c72a3c7 100644 --- a/src/openforms/js/compiled-lang/en.json +++ b/src/openforms/js/compiled-lang/en.json @@ -89,6 +89,12 @@ "value": "Default Value" } ], + "/cUjWg": [ + { + "type": 0, + "value": "The maximum number of allowed submissions for this form. Leave this empty if no limit is needed." + } + ], "/fAEsY": [ { "type": 0, @@ -1963,6 +1969,12 @@ "value": "Use existing form definition" } ], + "GJ8Ok2": [ + { + "type": 0, + "value": "Reset submissions counter" + } + ], "GO9yud": [ { "type": 0, @@ -2145,6 +2157,12 @@ "value": "Appointment enabled" } ], + "HrSXGN": [ + { + "type": 0, + "value": "You are about to reset the submissions counter and this action is irreversible. Are you sure that you want to do this?" + } + ], "HuAm1K": [ { "type": 0, @@ -5547,6 +5565,12 @@ "value": "Suffix (e.g. m²)" } ], + "oWOr9u": [ + { + "type": 0, + "value": "Submission" + } + ], "oXOxWz": [ { "type": 0, @@ -6079,6 +6103,12 @@ "value": "Description (omschrijving) of the ROLTYPE to use for employees filling in a form for a citizen/company." } ], + "u+IP17": [ + { + "type": 0, + "value": "Maximum allowed number of submissions" + } + ], "u/0IPY": [ { "type": 0, diff --git a/src/openforms/js/compiled-lang/nl.json b/src/openforms/js/compiled-lang/nl.json index f80acfc0da..f23f4d28d6 100644 --- a/src/openforms/js/compiled-lang/nl.json +++ b/src/openforms/js/compiled-lang/nl.json @@ -89,6 +89,12 @@ "value": "Standaardwaarde" } ], + "/cUjWg": [ + { + "type": 0, + "value": "The maximum number of allowed submissions for this form. Leave this empty if no limit is needed." + } + ], "/fAEsY": [ { "type": 0, @@ -1984,6 +1990,12 @@ "value": "Gebruik bestaande formulierdefinitie" } ], + "GJ8Ok2": [ + { + "type": 0, + "value": "Reset submissions counter" + } + ], "GO9yud": [ { "type": 0, @@ -2166,6 +2178,12 @@ "value": "Is afspraakformulier?" } ], + "HrSXGN": [ + { + "type": 0, + "value": "You are about to reset the submissions counter and this action is irreversible. Are you sure that you want to do this?" + } + ], "HuAm1K": [ { "type": 0, @@ -5569,6 +5587,12 @@ "value": "Suffix (bijv. m²)" } ], + "oWOr9u": [ + { + "type": 0, + "value": "Submission" + } + ], "oXOxWz": [ { "type": 0, @@ -6101,6 +6125,12 @@ "value": "Omschrijving van het ROLTYPE voor medewerkers die het formulier voor een burger/bedrijf invullen." } ], + "u+IP17": [ + { + "type": 0, + "value": "Maximum allowed number of submissions" + } + ], "u/0IPY": [ { "type": 0, diff --git a/src/openforms/js/components/admin/form_design/SubmissionFields.js b/src/openforms/js/components/admin/form_design/SubmissionFields.js new file mode 100644 index 0000000000..12451dfc2e --- /dev/null +++ b/src/openforms/js/components/admin/form_design/SubmissionFields.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import {useContext} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {APIContext} from 'components/admin/form_design/Context'; +import {FORM_ENDPOINT} from 'components/admin/form_design/constants'; +import ActionButton from 'components/admin/forms/ActionButton'; +import Field from 'components/admin/forms/Field'; +import Fieldset from 'components/admin/forms/Fieldset'; +import FormRow from 'components/admin/forms/FormRow'; +import {NumberInput} from 'components/admin/forms/Inputs'; +import {FormException} from 'utils/exception'; +import {patch} from 'utils/fetch'; + +import useConfirm from './useConfirm'; + +export const SubmissionLimitFields = ({submissionLimit, formUuid, onChange}) => { + const intl = useIntl(); + const {csrftoken} = useContext(APIContext); + const {ConfirmationModal, confirmationModalProps, openConfirmationModal} = useConfirm(); + + const handleChange = event => { + const {name, value: initialValue} = event.target; + // the backend must receive a value or null since it's a nullable integer field + const value = initialValue === '' ? null : initialValue; + const updatedEvent = {...event, target: {...event.target, name, value}}; + onChange(updatedEvent); + }; + + const resetCounter = async () => { + try { + const resetResult = await patch( + `${FORM_ENDPOINT}/${formUuid}`, + csrftoken, + {submissionCounter: 0}, + true + ); + if (!resetResult.ok) { + throw new FormException( + 'An error occurred while trying to reset the counter.', + resetResult.data + ); + } + return resetResult.data; + } catch (e) { + return null; + } + }; + + return ( + <> +
+ + + } + helpText={ + + } + > + + + + + { + event.preventDefault(); + const result = await openConfirmationModal(); + if (!result) return; + await resetCounter(); + }} + /> + +
+ + + } + /> + + ); +}; + +SubmissionLimitFields.propTypes = { + submissionLimit: PropTypes.number.isRequired, + formUuid: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; diff --git a/src/openforms/js/components/admin/form_design/form-creation-form.js b/src/openforms/js/components/admin/form_design/form-creation-form.js index 3e23c72b93..3adb06d2f8 100644 --- a/src/openforms/js/components/admin/form_design/form-creation-form.js +++ b/src/openforms/js/components/admin/form_design/form-creation-form.js @@ -34,6 +34,7 @@ import PaymentFields from './PaymentFields'; import PriceLogic from './PriceLogic'; import ProductFields from './ProductFields'; import RegistrationFields from './RegistrationFields'; +import {SubmissionLimitFields} from './SubmissionFields'; import Tab from './Tab'; import TextLiterals from './TextLiterals'; import {FormWarnings} from './Warnings'; @@ -100,6 +101,8 @@ const initialFormState = { maintenanceMode: false, translationEnabled: false, submissionAllowed: 'yes', + submissionLimit: null, + submission_counter: 0, suspensionAllowed: true, askPrivacyConsent: 'global_setting', askStatementOfTruth: 'global_setting', @@ -171,6 +174,7 @@ const FORM_FIELDS_TO_TAB_NAMES = { confirmationEmailTemplate: 'submission-confirmation', submissionAllowed: 'form', registrationBackends: 'registration', + submissionLimit: 'submission', product: 'product-payment', paymentBackend: 'product-payment', paymentBackendOptions: 'product-payment', @@ -1154,6 +1158,7 @@ const FormCreationForm = ({formUuid, formUrl, formHistoryUrl, outgoingRequestsUr const activeTab = new URLSearchParams(window.location.search).get('tab'); const {isAppointment = false} = state.form.appointmentOptions; + const {submissionLimit = null} = state.form; const numRulesWithProblems = state.logicRules.filter( rule => detectLogicProblems(rule, intl).length > 0 @@ -1248,6 +1253,12 @@ const FormCreationForm = ({formUuid, formUrl, formHistoryUrl, outgoingRequestsUr /> )} + + + @@ -1351,6 +1362,14 @@ const FormCreationForm = ({formUuid, formUrl, formHistoryUrl, outgoingRequestsUr )} + + + + diff --git a/src/openforms/js/lang/en.json b/src/openforms/js/lang/en.json index f1dbf3cc38..cdfe481ba4 100644 --- a/src/openforms/js/lang/en.json +++ b/src/openforms/js/lang/en.json @@ -44,6 +44,11 @@ "description": "action type \"step-applicable\" label", "originalDefault": "Mark the form step as applicable" }, + "/cUjWg": { + "defaultMessage": "The maximum number of allowed submissions for this form. Leave this empty if no limit is needed.", + "description": "Successful Submissions Removal Limit help text", + "originalDefault": "The maximum number of allowed submissions for this form. Leave this empty if no limit is needed." + }, "/fAEsY": { "defaultMessage": "Submission report CSV informatieobjecttype", "description": "Objects API registration options \"Submission report CSV informatieobjecttype\" label", @@ -874,6 +879,11 @@ "description": "Form definition selection modal title", "originalDefault": "Use existing form definition" }, + "GJ8Ok2": { + "defaultMessage": "Reset submissions counter", + "description": "Reset submissions counter", + "originalDefault": "Reset submissions counter" + }, "GO9yud": { "defaultMessage": "When the form should be activated.", "description": "Form activation field help text", @@ -999,6 +1009,11 @@ "description": "Form appointment enabled field label", "originalDefault": "Appointment enabled" }, + "HrSXGN": { + "defaultMessage": "You are about to reset the submissions counter and this action is irreversible. Are you sure that you want to do this?", + "description": "Reset the submissions counter confirmation message", + "originalDefault": "You are about to reset the submissions counter and this action is irreversible. Are you sure that you want to do this?" + }, "HuAm1K": { "defaultMessage": "and the step \"{step}\" has been reached", "description": "Additional 'trigger from step' condition", @@ -2584,6 +2599,11 @@ "description": "Email registration options 'attachFilesToEmail' label", "originalDefault": "Attach files to email" }, + "oWOr9u": { + "defaultMessage": "Submission", + "description": "Form submission options tab title", + "originalDefault": "Submission" + }, "oYmpN5": { "defaultMessage": "Whether to include the content of the confirmation page in the PDF.", "description": "Include confirmation page content in PDF", @@ -2834,6 +2854,11 @@ "description": "Objects API registration options 'medewerkerRoltype' help text", "originalDefault": "Description (omschrijving) of the ROLTYPE to use for employees filling in a form for a citizen/company." }, + "u+IP17": { + "defaultMessage": "Maximum allowed number of submissions", + "description": "Form submissionLimit field label", + "originalDefault": "Maximum allowed number of submissions" + }, "u/0IPY": { "defaultMessage": "House number addition Schema target", "description": "Objects registration variable mapping, addressNL component: 'options.houseNumberAddition schema target' label", diff --git a/src/openforms/js/lang/nl.json b/src/openforms/js/lang/nl.json index 26147f58ff..3e3c7a6a61 100644 --- a/src/openforms/js/lang/nl.json +++ b/src/openforms/js/lang/nl.json @@ -44,6 +44,11 @@ "description": "action type \"step-applicable\" label", "originalDefault": "Mark the form step as applicable" }, + "/cUjWg": { + "defaultMessage": "The maximum number of allowed submissions for this form. Leave this empty if no limit is needed.", + "description": "Successful Submissions Removal Limit help text", + "originalDefault": "The maximum number of allowed submissions for this form. Leave this empty if no limit is needed." + }, "/fAEsY": { "defaultMessage": "Informatieobjecttype CSV-document met inzendingsgegevens", "description": "Objects API registration options \"Submission report CSV informatieobjecttype\" label", @@ -883,6 +888,11 @@ "description": "Form definition selection modal title", "originalDefault": "Use existing form definition" }, + "GJ8Ok2": { + "defaultMessage": "Reset submissions counter", + "description": "Reset submissions counter", + "originalDefault": "Reset submissions counter" + }, "GO9yud": { "defaultMessage": "Datum en tijdstip waarop het formulier geactiveerd moet worden.", "description": "Form activation field help text", @@ -1008,6 +1018,11 @@ "description": "Form appointment enabled field label", "originalDefault": "Appointment enabled" }, + "HrSXGN": { + "defaultMessage": "You are about to reset the submissions counter and this action is irreversible. Are you sure that you want to do this?", + "description": "Reset the submissions counter confirmation message", + "originalDefault": "You are about to reset the submissions counter and this action is irreversible. Are you sure that you want to do this?" + }, "HuAm1K": { "defaultMessage": "en de stap \"{step}\" is bereikt", "description": "Additional 'trigger from step' condition", @@ -2605,6 +2620,11 @@ "description": "Email registration options 'attachFilesToEmail' label", "originalDefault": "Attach files to email" }, + "oWOr9u": { + "defaultMessage": "Submission", + "description": "Form submission options tab title", + "originalDefault": "Submission" + }, "oYmpN5": { "defaultMessage": "Vink aan om de inhoud toe te voegen aan de PDF die mensen op het eind kunnen downloaden.", "description": "Include confirmation page content in PDF", @@ -2855,6 +2875,11 @@ "description": "Objects API registration options 'medewerkerRoltype' help text", "originalDefault": "Description (omschrijving) of the ROLTYPE to use for employees filling in a form for a citizen/company." }, + "u+IP17": { + "defaultMessage": "Maximum allowed number of submissions", + "description": "Form submissionLimit field label", + "originalDefault": "Maximum allowed number of submissions" + }, "u/0IPY": { "defaultMessage": "Bestemmingspad huisnummertoevoeging", "description": "Objects registration variable mapping, addressNL component: 'options.houseNumberAddition schema target' label", diff --git a/src/openforms/js/utils/fetch.js b/src/openforms/js/utils/fetch.js index e41a15db97..d7e82f8f90 100644 --- a/src/openforms/js/utils/fetch.js +++ b/src/openforms/js/utils/fetch.js @@ -113,6 +113,11 @@ const put = async (url, csrftoken, data = {}, throwOn400 = false) => { return resp; }; +const patch = async (url, csrftoken, data = {}, throwOn400 = true) => { + const resp = await _unsafe('PATCH', url, csrftoken, data, throwOn400); + return resp; +}; + const apiDelete = async (url, csrftoken) => { const opts = { method: 'DELETE', @@ -127,4 +132,4 @@ const apiDelete = async (url, csrftoken) => { }; export {ValidationErrors}; -export {get, post, put, apiDelete, apiCall}; +export {get, post, put, patch, apiDelete, apiCall}; diff --git a/src/openforms/submissions/exceptions.py b/src/openforms/submissions/exceptions.py index 88eced144b..2d39228ca8 100644 --- a/src/openforms/submissions/exceptions.py +++ b/src/openforms/submissions/exceptions.py @@ -11,3 +11,8 @@ class FormDeactivated(UnprocessableEntity): class FormMaintenance(ServiceUnavailable): default_detail = _("The form is currently disabled for maintenance.") default_code = "form-maintenance" + + +class FormMaximumSubmissions(ServiceUnavailable): + default_detail = _("The form has reached the maximum number of submissions.") + default_code = "form-submission-limit-reached" diff --git a/src/openforms/submissions/signals.py b/src/openforms/submissions/signals.py index e2547f4cee..a4ac41c3e9 100644 --- a/src/openforms/submissions/signals.py +++ b/src/openforms/submissions/signals.py @@ -103,3 +103,12 @@ def increment_form_counter(sender, instance: Submission, **kwargs): form_statistics.submission_count = F("submission_count") + 1 form_statistics.last_submission = timezone.now() form_statistics.save() + + +@receiver( + submission_complete, dispatch_uid="submission.increment_submissions_form_counter" +) +def increment_submissions_form_counter(sender, instance: Submission, **kwargs): + if instance.form.submission_limit: + instance.form.submission_counter = F("submission_counter") + 1 + instance.form.save() diff --git a/src/openforms/submissions/templates/submissions/resume_form_error.html b/src/openforms/submissions/templates/submissions/resume_form_error.html index dcda69a120..42d825e0d9 100644 --- a/src/openforms/submissions/templates/submissions/resume_form_error.html +++ b/src/openforms/submissions/templates/submissions/resume_form_error.html @@ -23,6 +23,10 @@

{% blocktrans trimmed %} This form is currently undergoing maintenance. Please try again later. {% endblocktrans %} + {% elif error.detail.code == 'form-maximum-submissions' %} + {% blocktrans trimmed %} + Unfortunately, this form is no longer available for submissions. + {% endblocktrans %} {% endif %} diff --git a/src/openforms/submissions/tests/test_resume_form_view.py b/src/openforms/submissions/tests/test_resume_form_view.py index f53aa2cda0..cc71c17c68 100644 --- a/src/openforms/submissions/tests/test_resume_form_view.py +++ b/src/openforms/submissions/tests/test_resume_form_view.py @@ -13,6 +13,7 @@ from openforms.frontend.tests import FrontendRedirectMixin from ..constants import SUBMISSIONS_SESSION_KEY +from ..exceptions import FormMaximumSubmissions from ..tokens import submission_resume_token_generator from .factories import SubmissionFactory, SubmissionStepFactory @@ -456,3 +457,46 @@ def test_redirects_to_auth_if_form_does_not_require_login_but_user_logged_in_the response, expected_redirect_url.url, fetch_redirect_response=False ) self.assertNotIn(SUBMISSIONS_SESSION_KEY, self.client.session) + + def test_resume_with_form_max_submissions_limit_reached(self): + submission = SubmissionFactory.from_components( + completed=True, + components_list=[], + form_url="http://maykinmedia.nl/some-form/startpagina", + form__submission_limit=1, + form__submission_counter=1, + ) + + endpoint = reverse( + "submissions:resume", + kwargs={ + "token": submission_resume_token_generator.make_token(submission), + "submission_uuid": submission.uuid, + }, + ) + + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.context_data["error"], FormMaximumSubmissions) + + def test_resume_with_form_max_submissions_limit_not_reached(self): + submission = SubmissionFactory.from_components( + completed=True, + components_list=[], + form_url="http://maykinmedia.nl/some-form/startpagina", + form__submission_limit=2, + form__submission_counter=1, + ) + + endpoint = reverse( + "submissions:resume", + kwargs={ + "token": submission_resume_token_generator.make_token(submission), + "submission_uuid": submission.uuid, + }, + ) + + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 302) diff --git a/src/openforms/submissions/tests/test_start_submission.py b/src/openforms/submissions/tests/test_start_submission.py index ec46588ad2..9ab1879c6f 100644 --- a/src/openforms/submissions/tests/test_start_submission.py +++ b/src/openforms/submissions/tests/test_start_submission.py @@ -247,3 +247,30 @@ def test_start_submission_with_initial_data_reference(self): self.assertEqual( submission.initial_data_reference, body["initialDataReference"] ) + + def test_start_submission_with_form_max_submissions_limit_not_reached(self): + form = FormFactory.create(submission_limit=1) + FormStepFactory.create(form=form) + + form_url = reverse("api:form-detail", kwargs={"uuid_or_slug": form.uuid}) + body = { + "form": f"http://testserver.com{form_url}", + "formUrl": "http://testserver.com/my-form", + } + + response = self.client.post(self.endpoint, body) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_start_submission_with_form_max_submissions_limit_reached(self): + form = FormFactory.create(submission_limit=1, submission_counter=1) + FormStepFactory.create(form=form) + + form_url = reverse("api:form-detail", kwargs={"uuid_or_slug": form.uuid}) + body = { + "form": f"http://testserver.com{form_url}", + "formUrl": "http://testserver.com/my-form", + } + + response = self.client.post(self.endpoint, body) + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) diff --git a/src/openforms/submissions/utils.py b/src/openforms/submissions/utils.py index 762b5f036a..8184de59ab 100644 --- a/src/openforms/submissions/utils.py +++ b/src/openforms/submissions/utils.py @@ -30,7 +30,7 @@ from openforms.variables.constants import FormVariableSources from .constants import SUBMISSIONS_SESSION_KEY -from .exceptions import FormDeactivated, FormMaintenance +from .exceptions import FormDeactivated, FormMaintenance, FormMaximumSubmissions from .form_logic import check_submission_logic from .models import Submission, SubmissionReport, SubmissionValueVariable from .tokens import submission_report_token_generator @@ -281,6 +281,8 @@ def check_form_status( :raises: :class:`FormDeactivated` if the form is deactivated :raises: :class`FormMaintenance` if the form is in maintenance mode and the user is not a staff user. + :raises: :class:`FormMaximumSubmissions` if the form has reached the maximum amount + of submissions. """ # live forms -> shortcut, this is okay, proceed as usual if form.is_available: @@ -298,6 +300,10 @@ def check_form_status( if form.maintenance_mode and not request.user.is_staff: raise FormMaintenance() + # do not proceed if the form has reached the maximum number of submissions + if form.submissions_limit_reached: + raise FormMaximumSubmissions() + def get_report_download_url(request: Request, report: SubmissionReport) -> str: token = submission_report_token_generator.make_token(report) diff --git a/src/openforms/submissions/views.py b/src/openforms/submissions/views.py index e507938fbc..2587eedcde 100644 --- a/src/openforms/submissions/views.py +++ b/src/openforms/submissions/views.py @@ -28,7 +28,7 @@ from openforms.tokens import BaseTokenGenerator from .constants import RegistrationStatuses -from .exceptions import FormDeactivated, FormMaintenance +from .exceptions import FormDeactivated, FormMaintenance, FormMaximumSubmissions from .forms import SearchSubmissionForCosignForm from .models import Submission, SubmissionFileAttachment, SubmissionReport from .signals import submission_resumed @@ -46,10 +46,10 @@ class ResumeFormMixin(TemplateResponseMixin): def dispatch(self, request: HttpRequest, *args, **kwargs): try: return super().dispatch(request, *args, **kwargs) - except (FormDeactivated, FormMaintenance) as exc: + except (FormDeactivated, FormMaintenance, FormMaximumSubmissions) as exc: return self.render_to_response( context={"error": exc}, - status=exc.status_code if isinstance(exc, FormMaintenance) else 200, + status=(exc.status_code if isinstance(exc, FormMaintenance) else 200), ) def validate_url_and_get_submission(