Skip to content

Commit

Permalink
[#4321] Allow specific amount of submissions per form
Browse files Browse the repository at this point in the history
  • Loading branch information
vaszig committed Dec 12, 2024
1 parent 3ae753c commit 9270ede
Show file tree
Hide file tree
Showing 23 changed files with 544 additions and 12 deletions.
57 changes: 53 additions & 4 deletions src/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,8 @@ paths:
- `deactivateOn`
- `isDeleted`
- `submissionConfirmationTemplate`
- `submissionLimit`
- `submissionCounter`
- `askPrivacyConsent`
- `askStatementOfTruth`
- `submissionsRemovalOptions`
Expand Down Expand Up @@ -1407,6 +1409,8 @@ paths:
- `deactivateOn`
- `isDeleted`
- `submissionConfirmationTemplate`
- `submissionLimit`
- `submissionCounter`
- `askPrivacyConsent`
- `askStatementOfTruth`
- `submissionsRemovalOptions`
Expand Down Expand Up @@ -1922,6 +1926,8 @@ paths:
- `deactivateOn`
- `isDeleted`
- `submissionConfirmationTemplate`
- `submissionLimit`
- `submissionCounter`
- `askPrivacyConsent`
- `askStatementOfTruth`
- `submissionsRemovalOptions`
Expand Down Expand Up @@ -1999,6 +2005,8 @@ paths:
- `deactivateOn`
- `isDeleted`
- `submissionConfirmationTemplate`
- `submissionLimit`
- `submissionCounter`
- `askPrivacyConsent`
- `askStatementOfTruth`
- `submissionsRemovalOptions`
Expand Down Expand Up @@ -2080,6 +2088,8 @@ paths:
- `deactivateOn`
- `isDeleted`
- `submissionConfirmationTemplate`
- `submissionLimit`
- `submissionCounter`
- `askPrivacyConsent`
- `askStatementOfTruth`
- `submissionsRemovalOptions`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -7768,6 +7797,7 @@ components:
- resumeLinkLifetime
- slug
- steps
- submissionLimitReached
- submissionReportDownloadLinkTitle
- submissionStatementsConfiguration
- url
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 27 additions & 1 deletion src/openforms/forms/admin/form.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -129,6 +154,7 @@ class FormAdmin(
"maintenance_mode",
"translation_enabled",
FormDeletedListFilter,
FormReachedSubmissionLimitListFilter,
)
search_fields = ("uuid", "name", "internal_name", "slug")

Expand Down
11 changes: 11 additions & 0 deletions src/openforms/forms/api/serializers/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -299,6 +303,7 @@ class Meta:
"active",
"required_fields_with_asterisk",
"submission_allowed",
"submission_limit_reached",
"suspension_allowed",
"send_confirmation_email",
"appointment_options",
Expand Down Expand Up @@ -513,6 +518,12 @@ 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:
if not obj:
return False

return obj.submissions_limit_reached


FormSerializer.__doc__ = FormSerializer.__doc__.format(
admin_fields=", ".join(
Expand Down
10 changes: 10 additions & 0 deletions src/openforms/forms/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,16 @@ def retrieve(self, request, *args, **kwargs):
set_language_cookie(response, current_language)
return response

def partial_update(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=True)

if serializer.is_valid():
self.perform_update(serializer)
return Response(serializer.data)
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

@extend_schema(
summary=_("Export form"),
tags=["forms"],
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
38 changes: 36 additions & 2 deletions src/openforms/forms/models/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=_(
Expand Down Expand Up @@ -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})

Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions src/openforms/forms/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 19 additions & 0 deletions src/openforms/forms/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"], ["[email protected]"])

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(context=context).to_representation(form)

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(context=context).to_representation(form)

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(context=context).to_representation(form)

self.assertTrue(data["submission_limit_reached"])
3 changes: 3 additions & 0 deletions src/openforms/forms/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
Loading

0 comments on commit 9270ede

Please sign in to comment.