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 11, 2024
1 parent 27a9852 commit 77c5a24
Show file tree
Hide file tree
Showing 21 changed files with 531 additions and 8 deletions.
19 changes: 18 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 Count, F, Q
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,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(

Check warning on line 60 in src/openforms/forms/admin/form.py

View check run for this annotation

Codecov / codecov/patch

src/openforms/forms/admin/form.py#L60

Added line #L60 was not covered by tests
~Q(submission_maximum_allowed=None)
& Q(submission_maximum_allowed=F("submission_counter"))
)


class FormDeletedListFilter(admin.ListFilter):
title = _("is deleted")
parameter_name = "deleted"
Expand Down Expand Up @@ -112,6 +127,7 @@ class FormAdmin(
"active",
"maintenance_mode",
"translation_enabled",
"submission_maximum_allowed",
"get_authentication_backends_display",
"get_payment_backend_display",
"get_registration_backend_display",
Expand All @@ -129,6 +145,7 @@ class FormAdmin(
"maintenance_mode",
"translation_enabled",
FormDeletedListFilter,
FormReachedSubmissionLimitListFilter,
)
search_fields = ("uuid", "name", "internal_name", "slug")

Expand Down
29 changes: 29 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,13 @@ class FormSerializer(PublicFieldsSerializerMixin, serializers.ModelSerializer):
"of type 'checkbox'."
),
)
submission_maximum_allowed = serializers.IntegerField(
allow_null=True, required=False
)
submission_counter = serializers.IntegerField(
allow_null=True, required=False, read_only=True
)
submission_limit_reached = serializers.SerializerMethodField()
brp_personen_request_options = BRPPersonenRequestOptionsSerializer(
required=False, allow_null=True
)
Expand Down Expand Up @@ -257,6 +264,9 @@ class Meta:
"introduction_page_content",
"explanation_template",
"submission_allowed",
"submission_maximum_allowed",
"submission_counter",
"submission_limit_reached",
"suspension_allowed",
"ask_privacy_consent",
"ask_statement_of_truth",
Expand Down Expand Up @@ -299,6 +309,7 @@ class Meta:
"active",
"required_fields_with_asterisk",
"submission_allowed",
"submission_limit_reached",
"suspension_allowed",
"send_confirmation_email",
"appointment_options",
Expand Down Expand Up @@ -448,6 +459,17 @@ def validate_auto_login_backend(self, attrs):
}
)

def validate_submission_maximum_allowed(self, value):
if form := self.instance:
if value and value <= form.submission_counter:
raise serializers.ValidationError(
_(
"The maximum amount of allowed submissions must be bigger than the existing amount of submissions."
"Consider resetting the submissions counter."
)
)
return value

def get_required_fields_with_asterisk(self, obj) -> bool:
config = GlobalConfiguration.get_solo()
return config.form_display_required_with_asterisk
Expand Down Expand Up @@ -513,6 +535,13 @@ def get_cosign_has_link_in_email(self, obj: Form) -> bool:
config = GlobalConfiguration.get_solo()
return config.cosign_request_template_has_link

@extend_schema_field(OpenApiTypes.BOOL)
def get_submission_limit_reached(self, obj: Form):
if obj and obj.submission_maximum_allowed:
if obj.submission_maximum_allowed == obj.submission_counter:
return True
return False


FormSerializer.__doc__ = FormSerializer.__doc__.format(
admin_fields=", ".join(
Expand Down
12 changes: 12 additions & 0 deletions src/openforms/forms/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,18 @@ def perform_destroy(self, instance):
instance._is_deleted = True
instance.save()

@action(detail=True, methods=["patch"])
def reset_submission_counter(self, request, **kwargs):
instance = self.get_object()
instance.submission_counter = 0
instance.save()

Check warning on line 428 in src/openforms/forms/api/viewsets.py

View check run for this annotation

Codecov / codecov/patch

src/openforms/forms/api/viewsets.py#L426-L428

Added lines #L426 - L428 were not covered by tests

updated_data = {

Check warning on line 430 in src/openforms/forms/api/viewsets.py

View check run for this annotation

Codecov / codecov/patch

src/openforms/forms/api/viewsets.py#L430

Added line #L430 was not covered by tests
"uuid": instance.uuid,
"submission_counter": instance.submission_counter,
}
return Response(updated_data, status=status.HTTP_200_OK)

Check warning on line 434 in src/openforms/forms/api/viewsets.py

View check run for this annotation

Codecov / codecov/patch

src/openforms/forms/api/viewsets.py#L434

Added line #L434 was not covered by tests

@extend_schema(
summary=_("Prepare form edit admin message"),
tags=["admin"],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 4.2.16 on 2024-12-05 13:41

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_maximum_allowed",
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",
),
),
]
37 changes: 35 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_maximum_allowed = 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,27 @@ 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 (
limit := self.submission_maximum_allowed
) 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 +517,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_maximum_allowed=2, submission_counter=2
)
self.assertFalse(form.is_available)

def test_form_is_available_when_limit_not_reached(self):
form: Form = FormFactory.create(
submission_maximum_allowed=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
45 changes: 45 additions & 0 deletions src/openforms/forms/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, TestCase
from django.utils.translation import gettext as _

from hypothesis import given
from hypothesis.extra.django import TestCase as HypothesisTestCase
Expand Down Expand Up @@ -333,3 +334,47 @@ 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_form_with_submission_max_and_submission_counter(self):
context = {"request": None}

with self.subTest("submission_max_allowed equal to submission_counter"):
form = FormFactory.create(
submission_maximum_allowed=2, submission_counter=2
)
data = FormSerializer(context=context).to_representation(form)
serializer = FormSerializer(instance=form, data=data)

expected_error = _(
"The maximum amount of allowed submissions must be bigger than the existing amount of submissions.Consider resetting the submissions counter."
)

self.assertFalse(serializer.is_valid())
self.assertIn("submission_maximum_allowed", serializer.errors)
self.assertEqual(
serializer.errors["submission_maximum_allowed"][0], expected_error
)
with self.subTest("submission_max_allowed bigger than submission_counter"):
form = FormFactory.create(
submission_maximum_allowed=2, submission_counter=1
)
data = FormSerializer(context=context).to_representation(form)
serializer = FormSerializer(instance=form, data=data)

self.assertTrue(serializer.is_valid())
with self.subTest("submission_max_allowed smaller than submission_counter"):
form = FormFactory.create(
submission_maximum_allowed=1, submission_counter=2
)
data = FormSerializer(context=context).to_representation(form)
serializer = FormSerializer(instance=form, data=data)

expected_error = _(
"The maximum amount of allowed submissions must be bigger than the existing amount of submissions.Consider resetting the submissions counter."
)

self.assertFalse(serializer.is_valid())
self.assertIn("submission_maximum_allowed", serializer.errors)
self.assertEqual(
serializer.errors["submission_maximum_allowed"][0], expected_error
)
30 changes: 30 additions & 0 deletions src/openforms/js/compiled-lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@
"value": "Locations Component"
}
],
"/cUjWg": [
{
"type": 0,
"value": "The maximum number of allowed submissions for this form. Leave this empty if no limit is needed."
}
],
"/fAEsY": [
{
"type": 0,
Expand Down Expand Up @@ -1993,6 +1999,12 @@
"value": "Use existing form definition"
}
],
"GJ8Ok2": [
{
"type": 0,
"value": "Reset submissions counter"
}
],
"GO9yud": [
{
"type": 0,
Expand Down Expand Up @@ -2175,6 +2187,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,
Expand Down Expand Up @@ -5607,6 +5625,12 @@
"value": "Suffix (e.g. m²)"
}
],
"oWOr9u": [
{
"type": 0,
"value": "Submission"
}
],
"oXOxWz": [
{
"type": 0,
Expand Down Expand Up @@ -6007,6 +6031,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,
Expand Down
Loading

0 comments on commit 77c5a24

Please sign in to comment.