Skip to content

Commit

Permalink
Merge pull request #4885 from open-formulieren/feature/4321-allow-max…
Browse files Browse the repository at this point in the history
…imum-number-of-submissions

[#4321] Allow max number of submissions per form
  • Loading branch information
sergei-maertens authored Dec 13, 2024
2 parents 3a70208 + a21cc64 commit 753691c
Show file tree
Hide file tree
Showing 27 changed files with 651 additions and 54 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
8 changes: 8 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,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(
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
43 changes: 43 additions & 0 deletions src/openforms/forms/tests/admin/mixins.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 753691c

Please sign in to comment.