From c23efc9d5f9e9355a187f6bebc900b9d02c15679 Mon Sep 17 00:00:00 2001 From: vasileios Date: Tue, 3 Dec 2024 17:00:42 +0100 Subject: [PATCH] [#4321] First attempt to limit the amount of submissions per form --- src/openforms/forms/admin/form.py | 29 ++++++++++++- src/openforms/forms/api/serializers/form.py | 14 ++++++ .../0107_form_submission_counter_and_more.py | 41 ++++++++++++++++++ src/openforms/forms/models/form.py | 41 +++++++++++++++++- src/openforms/js/compiled-lang/en.json | 24 +++++++++++ src/openforms/js/compiled-lang/nl.json | 24 +++++++++++ .../form_design/FormConfigurationFields.js | 43 ++++++++++++++++++- .../admin/form_design/form-creation-form.js | 4 ++ src/openforms/js/lang/en.json | 20 +++++++++ src/openforms/js/lang/nl.json | 20 +++++++++ src/openforms/submissions/exceptions.py | 7 +++ src/openforms/submissions/signals.py | 9 ++++ .../submissions/resume_form_error.html | 4 ++ src/openforms/submissions/utils.py | 8 +++- src/openforms/submissions/views.py | 6 +-- 15 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 src/openforms/forms/migrations/0107_form_submission_counter_and_more.py diff --git a/src/openforms/forms/admin/form.py b/src/openforms/forms/admin/form.py index 3e4f5f815e..82d4c12497 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 Count, F, Q from django.http.response import HttpResponse, HttpResponseRedirect from django.template.response import TemplateResponse from django.urls import path, reverse @@ -48,6 +48,21 @@ 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 (("unavailable", "Unavailable for submission"),) + +# def queryset(self, request, queryset): +# if self.value() == "unavailable": +# return queryset.filter( +# ~Q(submission_maximum_allowed=0) +# & Q(submission_maximum_allowed=F("submission_counter")) +# ) + + class FormDeletedListFilter(admin.ListFilter): title = _("is deleted") parameter_name = "deleted" @@ -112,6 +127,7 @@ class FormAdmin( "active", "maintenance_mode", "translation_enabled", + "submission_maximum_allowed_display", "get_authentication_backends_display", "get_payment_backend_display", "get_registration_backend_display", @@ -129,11 +145,22 @@ class FormAdmin( "maintenance_mode", "translation_enabled", FormDeletedListFilter, + FormReachedSubmissionLimitListFilter, ) search_fields = ("uuid", "name", "internal_name", "slug") change_list_template = "admin/forms/form/change_list.html" + def submission_maximum_allowed_display(self, obj): + """Show a friendly label for the submission_maximum_allowed field.""" + return ( + obj.submission_maximum_allowed + if obj.submission_maximum_allowed != 0 + else "-" + ) + + submission_maximum_allowed_display.short_description = "Max submissions" + def changelist_view(self, request, extra_context=None): if request.GET.get("_async"): return self._async_changelist_view(request) diff --git a/src/openforms/forms/api/serializers/form.py b/src/openforms/forms/api/serializers/form.py index 07c938a3b1..a84a5e13ae 100644 --- a/src/openforms/forms/api/serializers/form.py +++ b/src/openforms/forms/api/serializers/form.py @@ -269,6 +269,8 @@ class Meta: "introduction_page_content", "explanation_template", "submission_allowed", + "submission_maximum_allowed", + "submission_counter_reset", "suspension_allowed", "ask_privacy_consent", "ask_statement_of_truth", @@ -311,6 +313,8 @@ class Meta: "active", "required_fields_with_asterisk", "submission_allowed", + "submission_maximum_allowed", + "submission_counter_reset", "suspension_allowed", "appointment_options", "resume_link_lifetime", @@ -405,6 +409,16 @@ def _update_v2_registration_backend(self, form, validated_data): @transaction.atomic() def update(self, instance, validated_data): + # # This is the case where we used to have a submission_maximum_allowed limit and then we + # # changed it to zero (no limit) or to something else. The counter should be reset as well, + # # in order to be able to rely on it again (This does not affect the form statistics). + # if ( + # validated_data["submission_maximum_allowed"] == 0 + # or validated_data["submission_maximum_allowed"] + # <= instance.submission_counter + # ): + # instance.submission_counter = 0 + confirmation_email_template = validated_data.pop( "confirmation_email_template", None ) diff --git a/src/openforms/forms/migrations/0107_form_submission_counter_and_more.py b/src/openforms/forms/migrations/0107_form_submission_counter_and_more.py new file mode 100644 index 0000000000..2cf5c2bc87 --- /dev/null +++ b/src/openforms/forms/migrations/0107_form_submission_counter_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.16 on 2024-12-03 15:48 + +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.", + verbose_name="submissions counter", + ), + ), + migrations.AddField( + model_name="form", + name="submission_counter_reset", + field=models.BooleanField( + default=True, + help_text="Whether the counter of the submissions should be reset or not.", + verbose_name="submission counter reset", + ), + ), + migrations.AddField( + model_name="form", + name="submission_maximum_allowed", + field=models.PositiveIntegerField( + blank=True, + default=0, + help_text="Maximum number of allowed submissions per form. Set this to zero (0) if no limit is needed.", + verbose_name="maximum allowed submissions", + ), + ), + ] diff --git a/src/openforms/forms/models/form.py b/src/openforms/forms/models/form.py index b08c994c73..b3432ead72 100644 --- a/src/openforms/forms/models/form.py +++ b/src/openforms/forms/models/form.py @@ -126,6 +126,27 @@ class Form(models.Model): ) # submission + submission_maximum_allowed = models.PositiveIntegerField( + _("maximum allowed submissions"), + default=0, + blank=True, + help_text=_( + "Maximum number of allowed submissions per form. Set this to zero (0) 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." + ), + ) + submission_counter_reset = models.BooleanField( + _("submission counter reset"), + default=True, + help_text=_("Whether the counter of the submissions should be reset or not."), + ) submission_confirmation_template = HTMLField( _("submission confirmation template"), help_text=_( @@ -382,12 +403,28 @@ def __str__(self): @property def is_available(self) -> bool: """ - Soft deleted, deactivated or forms in maintenance mode are not available. + 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.has_reached_submissions_limit(), + ) + ): return False return True + def has_reached_submissions_limit(self) -> bool: + if ( + self.submission_maximum_allowed != 0 + and self.submission_maximum_allowed <= self.submission_counter + ): + return True + return False + def get_absolute_url(self): return reverse("forms:form-detail", kwargs={"slug": self.slug}) diff --git a/src/openforms/js/compiled-lang/en.json b/src/openforms/js/compiled-lang/en.json index 19f6fc0f02..3e5a647f59 100644 --- a/src/openforms/js/compiled-lang/en.json +++ b/src/openforms/js/compiled-lang/en.json @@ -777,6 +777,12 @@ "value": "Something went wrong while retrieving the available object types." } ], + "6umAJ3": [ + { + "type": 0, + "value": "Whether the counter of the submissions should be reset or not." + } + ], "7/Bhpw": [ { "type": 0, @@ -3185,6 +3191,12 @@ "value": "Add variable" } ], + "SmwbfM": [ + { + "type": 0, + "value": "Submission counter reset" + } + ], "Su4nqf": [ { "type": 0, @@ -5463,6 +5475,12 @@ "value": "Version" } ], + "n8/j1j": [ + { + "type": 0, + "value": "The maximum number of allowed submissions for this form. Set this to zero (0) if no limit is needed." + } + ], "n9T2Oy": [ { "type": 0, @@ -5989,6 +6007,12 @@ "value": "Enable to attach file uploads to the registration email. If set, this overrides the global default. Form designers should take special care to ensure that the total file upload sizes do not exceed the email size limit." } ], + "sQekFr": [ + { + "type": 0, + "value": "Maximum allowed number of submissions" + } + ], "sR9GVQ": [ { "type": 0, diff --git a/src/openforms/js/compiled-lang/nl.json b/src/openforms/js/compiled-lang/nl.json index 0c6c0e3eca..dffa6ea953 100644 --- a/src/openforms/js/compiled-lang/nl.json +++ b/src/openforms/js/compiled-lang/nl.json @@ -777,6 +777,12 @@ "value": "Er gings iets fout bij het ophalen van de beschikabre objecttypes." } ], + "6umAJ3": [ + { + "type": 0, + "value": "Whether the counter of the submissions should be reset or not." + } + ], "7/Bhpw": [ { "type": 0, @@ -3202,6 +3208,12 @@ "value": "Variabele toevoegen" } ], + "SmwbfM": [ + { + "type": 0, + "value": "Submission counter reset" + } + ], "Su4nqf": [ { "type": 0, @@ -5485,6 +5497,12 @@ "value": "Versie" } ], + "n8/j1j": [ + { + "type": 0, + "value": "The maximum number of allowed submissions for this form. Set this to zero (0) if no limit is needed." + } + ], "n9T2Oy": [ { "type": 0, @@ -6011,6 +6029,12 @@ "value": "Vink aan om gebruikersbestanden als bijlage aan de registratiemail toe te voegen. Als een waarde gezet is, dan heeft deze hogere prioriteit dan de globale configuratie. Formulierbeheerders moeten ervoor zorgen dat de totale maximale bestandsgrootte onder de maximale e-mailbestandsgrootte blijft." } ], + "sQekFr": [ + { + "type": 0, + "value": "Maximum allowed number of submissions" + } + ], "sR9GVQ": [ { "type": 0, diff --git a/src/openforms/js/components/admin/form_design/FormConfigurationFields.js b/src/openforms/js/components/admin/form_design/FormConfigurationFields.js index c242a57f3b..cdb60e8600 100644 --- a/src/openforms/js/components/admin/form_design/FormConfigurationFields.js +++ b/src/openforms/js/components/admin/form_design/FormConfigurationFields.js @@ -6,7 +6,7 @@ import LoAOverrideOption from 'components/admin/form_design/authentication/LoAOv import Field from 'components/admin/forms/Field'; import Fieldset from 'components/admin/forms/Fieldset'; import FormRow from 'components/admin/forms/FormRow'; -import {Checkbox, DateTimeInput, TextInput} from 'components/admin/forms/Inputs'; +import {Checkbox, DateTimeInput, NumberInput, TextInput} from 'components/admin/forms/Inputs'; import Select from 'components/admin/forms/Select'; import {getTranslatedChoices} from 'utils/i18n'; @@ -107,6 +107,8 @@ const FormConfigurationFields = ({ maintenanceMode, translationEnabled, submissionAllowed, + submissionMaximumAllowed, + submissionCounterReset, suspensionAllowed, askPrivacyConsent, askStatementOfTruth, @@ -432,6 +434,44 @@ const FormConfigurationFields = ({ /> + + + } + helpText={ + + } + > + + + + + } + helpText={ + + } + checked={submissionCounterReset} + onChange={event => onCheckboxChange(event, submissionCounterReset)} + /> + + {!isAppointment && ( @@ -538,6 +578,7 @@ FormConfigurationFields.propTypes = { maintenanceMode: PropTypes.bool.isRequired, translationEnabled: PropTypes.bool.isRequired, submissionAllowed: PropTypes.oneOf(SUMBISSION_ALLOWED_CHOICES.map(opt => opt[0])), + submissionMaximumAllowed: PropTypes.number.isRequired, suspensionAllowed: PropTypes.bool.isRequired, askPrivacyConsent: statementChoices.isRequired, askStatementOfTruth: statementChoices.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 3fd8752430..babb100581 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 @@ -102,6 +102,8 @@ const initialFormState = { maintenanceMode: false, translationEnabled: false, submissionAllowed: 'yes', + submissionMaximumAllowed: 0, + submissionCounterReset: false, suspensionAllowed: true, askPrivacyConsent: 'global_setting', askStatementOfTruth: 'global_setting', @@ -172,6 +174,8 @@ const FORM_FIELDS_TO_TAB_NAMES = { translationEnabled: 'form', confirmationEmailTemplate: 'submission-confirmation', submissionAllowed: 'form', + submissionMaximumAllowed: 'form', + submissionCounterReset: 'form', registrationBackends: 'registration', product: 'product-payment', paymentBackend: 'product-payment', diff --git a/src/openforms/js/lang/en.json b/src/openforms/js/lang/en.json index fdcbd2d0b0..46819f90ac 100644 --- a/src/openforms/js/lang/en.json +++ b/src/openforms/js/lang/en.json @@ -339,6 +339,11 @@ "description": "Objects API prefill options: object type select error", "originalDefault": "Something went wrong while retrieving the available object types." }, + "6umAJ3": { + "defaultMessage": "Whether the counter of the submissions should be reset or not.", + "description": "Form submissionCounterReset field help text", + "originalDefault": "Whether the counter of the submissions should be reset or not." + }, "7/Bhpw": { "defaultMessage": "Please fill in the name of the previous variable before adding another", "description": "Warning to finish invalid user defined variable", @@ -1564,6 +1569,11 @@ "description": "Add zaakeigenschap (mapping) button", "originalDefault": "Add variable" }, + "SmwbfM": { + "defaultMessage": "Submission counter reset", + "description": "Form submissionCounterReset field label", + "originalDefault": "Submission counter reset" + }, "Sw6TLH": { "defaultMessage": "Document type", "description": "ZGW APIs registration options 'DocumentType' label", @@ -2559,6 +2569,11 @@ "description": "Camunda 'process definition version' label", "originalDefault": "Version" }, + "n8/j1j": { + "defaultMessage": "The maximum number of allowed submissions for this form. Set this to zero (0) if no limit is needed.", + "description": "Form submissionMaximumAllowed field help text", + "originalDefault": "The maximum number of allowed submissions for this form. Set this to zero (0) if no limit is needed." + }, "nFXYHB": { "defaultMessage": "When the form should be deactivated.", "description": "Form deactivation field help text", @@ -2814,6 +2829,11 @@ "description": "Email registration options 'attachFilesToEmail' helpText", "originalDefault": "Enable to attach file uploads to the registration email. If set, this overrides the global default. Form designers should take special care to ensure that the total file upload sizes do not exceed the email size limit." }, + "sQekFr": { + "defaultMessage": "Maximum allowed number of submissions", + "description": "Form submissionMaximumAllowed field label", + "originalDefault": "Maximum allowed number of submissions" + }, "sptpzv": { "defaultMessage": "Switching to the new registration options will remove the existing JSON templates. You will also not be able to save the form until the variables are correctly mapped. Are you sure you want to continue?", "description": "Objects API registration backend: v2 switch warning message", diff --git a/src/openforms/js/lang/nl.json b/src/openforms/js/lang/nl.json index fcd8b35b7b..bbc6e5db55 100644 --- a/src/openforms/js/lang/nl.json +++ b/src/openforms/js/lang/nl.json @@ -343,6 +343,11 @@ "description": "Objects API prefill options: object type select error", "originalDefault": "Something went wrong while retrieving the available object types." }, + "6umAJ3": { + "defaultMessage": "Whether the counter of the submissions should be reset or not.", + "description": "Form submissionCounterReset field help text", + "originalDefault": "Whether the counter of the submissions should be reset or not." + }, "7/Bhpw": { "defaultMessage": "Vul eerst de naam van de vorige variabele in voor u een nieuwe toevoegt", "description": "Warning to finish invalid user defined variable", @@ -1579,6 +1584,11 @@ "description": "Add zaakeigenschap (mapping) button", "originalDefault": "Add variable" }, + "SmwbfM": { + "defaultMessage": "Submission counter reset", + "description": "Form submissionCounterReset field label", + "originalDefault": "Submission counter reset" + }, "Sw6TLH": { "defaultMessage": "Documenttype", "description": "ZGW APIs registration options 'DocumentType' label", @@ -2580,6 +2590,11 @@ "description": "Camunda 'process definition version' label", "originalDefault": "Version" }, + "n8/j1j": { + "defaultMessage": "The maximum number of allowed submissions for this form. Set this to zero (0) if no limit is needed.", + "description": "Form submissionMaximumAllowed field help text", + "originalDefault": "The maximum number of allowed submissions for this form. Set this to zero (0) if no limit is needed." + }, "nFXYHB": { "defaultMessage": "Datum en tijdstip waarop het formulier gedeactiveerd moet worden.", "description": "Form deactivation field help text", @@ -2835,6 +2850,11 @@ "description": "Email registration options 'attachFilesToEmail' helpText", "originalDefault": "Enable to attach file uploads to the registration email. If set, this overrides the global default. Form designers should take special care to ensure that the total file upload sizes do not exceed the email size limit." }, + "sQekFr": { + "defaultMessage": "Maximum allowed number of submissions", + "description": "Form submissionMaximumAllowed field label", + "originalDefault": "Maximum allowed number of submissions" + }, "sptpzv": { "defaultMessage": "Let op! Migreren naar het nieuwe configuratieformaat maakt de bestaande JSON-sjablonen leeg. Daarnaast kan je het formulier pas opslaan als alle verplichte variabelen goed gekoppeld zijn. Ben je zeker dat je wil migreren?", "description": "Objects API registration backend: v2 switch warning message", diff --git a/src/openforms/submissions/exceptions.py b/src/openforms/submissions/exceptions.py index 88eced144b..f7eba0a403 100644 --- a/src/openforms/submissions/exceptions.py +++ b/src/openforms/submissions/exceptions.py @@ -11,3 +11,10 @@ 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 currently reached the maximum amount of submissions." + ) + default_code = "form-maximum-submissions" diff --git a/src/openforms/submissions/signals.py b/src/openforms/submissions/signals.py index e2547f4cee..99e5c44ccb 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_maximum_allowed: + instance.form.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/utils.py b/src/openforms/submissions/utils.py index c88100587e..11520e9559 100644 --- a/src/openforms/submissions/utils.py +++ b/src/openforms/submissions/utils.py @@ -31,7 +31,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 @@ -284,6 +284,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: @@ -301,6 +303,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 amount of submissions + if form.has_reached_submissions_limit(): + 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(