Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#4321] Allow max number of submissions per form #4885

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
vaszig marked this conversation as resolved.
Show resolved Hide resolved

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)
vaszig marked this conversation as resolved.
Show resolved Hide resolved


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.
vaszig marked this conversation as resolved.
Show resolved Hide resolved
"""
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens in export/import?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we should have the same approach right?Resetting the counter during export.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes :)


# 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
Loading