From b4efb1e857b1eaa3f8cd6a89b173615ad37d6af0 Mon Sep 17 00:00:00 2001 From: berin Date: Wed, 5 Jan 2022 13:53:17 -0300 Subject: [PATCH] Enable to have sponsorship applications only with a la carte benefits (#1946) * Add boolean field to flag a la carte benefits * Fix typo in form name * Display a la carte boolean and admin page and raise error if a la carte with associated package * Add filter for every benefit boolean flag * Add new field to form to list a la carte benefits * Make sure package is required, but not if submission only with a la carte * Remove unecessary end of regex and avoid warning message * Add slug to sponsorship packages to make it easier to uniquely reference them * Remove default value after running data migration * Assign a custom package if sponsorship application only with a la carte benefits * Make sure the view can handle a la carte only applications * Update sponsorships application form to list a la carte benefits * Propagate a la carte flag on SponsorBenefit objects * update all step titles in sponsorship form to a new svg plus bonus step if ever needed :-D Co-authored-by: Ee Durbin --- mailing/admin.py | 2 +- sponsors/admin.py | 21 ++- sponsors/forms.py | 62 +++++++- .../migrations/0063_auto_20211220_1422.py | 28 ++++ .../0064_sponsorshippackage_slug.py | 18 +++ .../migrations/0065_auto_20211223_1309.py | 23 +++ .../migrations/0066_auto_20211223_1318.py | 18 +++ .../0067_sponsorbenefit_a_la_carte.py | 18 +++ sponsors/models/managers.py | 7 +- sponsors/models/sponsors.py | 5 + sponsors/models/sponsorship.py | 7 + sponsors/tests/test_forms.py | 139 ++++++++++++++---- sponsors/tests/test_managers.py | 22 ++- sponsors/tests/test_models.py | 10 ++ sponsors/tests/test_views.py | 59 +++++++- sponsors/views.py | 13 +- static/img/sponsors/title-1.png | Bin 953 -> 0 bytes static/img/sponsors/title-1.svg | 1 + static/img/sponsors/title-2.png | Bin 1055 -> 0 bytes static/img/sponsors/title-2.svg | 1 + static/img/sponsors/title-3.png | Bin 1138 -> 0 bytes static/img/sponsors/title-3.svg | 1 + static/img/sponsors/title-4.png | Bin 1027 -> 0 bytes static/img/sponsors/title-4.svg | 1 + static/img/sponsors/title-5.svg | 1 + static/img/sponsors/title-6.svg | 1 + static/sass/style.css | 18 +-- static/sass/style.scss | 2 +- .../sponsors/sponsorship_benefits_form.html | 93 +++++++++--- 29 files changed, 483 insertions(+), 88 deletions(-) create mode 100644 sponsors/migrations/0063_auto_20211220_1422.py create mode 100644 sponsors/migrations/0064_sponsorshippackage_slug.py create mode 100644 sponsors/migrations/0065_auto_20211223_1309.py create mode 100644 sponsors/migrations/0066_auto_20211223_1318.py create mode 100644 sponsors/migrations/0067_sponsorbenefit_a_la_carte.py delete mode 100644 static/img/sponsors/title-1.png create mode 100644 static/img/sponsors/title-1.svg delete mode 100644 static/img/sponsors/title-2.png create mode 100644 static/img/sponsors/title-2.svg delete mode 100644 static/img/sponsors/title-3.png create mode 100644 static/img/sponsors/title-3.svg delete mode 100644 static/img/sponsors/title-4.png create mode 100644 static/img/sponsors/title-4.svg create mode 100644 static/img/sponsors/title-5.svg create mode 100644 static/img/sponsors/title-6.svg diff --git a/mailing/admin.py b/mailing/admin.py index 76aad1da1..72f78222b 100644 --- a/mailing/admin.py +++ b/mailing/admin.py @@ -34,7 +34,7 @@ def get_urls(self): prefix = self.model._meta.db_table my_urls = [ path( - "/preview-content/$", + "/preview-content/", self.admin_site.admin_view(self.preview_email_template), name=f"{prefix}_preview", ), diff --git a/sponsors/admin.py b/sponsors/admin.py index ffbc61aab..549c5e2cd 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -13,7 +13,8 @@ from mailing.admin import BaseEmailTemplateAdmin from sponsors.models import * from sponsors import views_admin -from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm, RequiredImgAssetConfigurationForm +from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm, RequiredImgAssetConfigurationForm, \ + SponsorshipBenefitAdminForm from cms.admin import ContentManageableModelAdmin @@ -88,8 +89,9 @@ class SponsorshipBenefitAdmin(PolymorphicInlineSupportMixin, OrderedModelAdmin): "internal_value", "move_up_down_links", ] - list_filter = ["program", "package_only", "packages"] + list_filter = ["program", "package_only", "packages", "new", "a_la_carte", "unavailable"] search_fields = ["name"] + form = SponsorshipBenefitAdminForm fieldsets = [ ( @@ -103,6 +105,7 @@ class SponsorshipBenefitAdmin(PolymorphicInlineSupportMixin, OrderedModelAdmin): "package_only", "new", "unavailable", + "a_la_carte", ), }, ), @@ -144,9 +147,17 @@ class SponsorshipPackageAdmin(OrderedModelAdmin): search_fields = ["name"] def get_readonly_fields(self, request, obj=None): - if request.user.is_superuser: - return [] - return ["logo_dimension"] + readonly = [] + if obj: + readonly.append("slug") + if not request.user.is_superuser: + readonly.append("logo_dimension") + return readonly + + def get_prepopulated_fields(self, request, obj=None): + if not obj: + return {'slug': ['name']} + return {} class SponsorContactInline(admin.TabularInline): diff --git a/sponsors/forms.py b/sponsors/forms.py index 54bbeb1a3..ee845a0a7 100644 --- a/sponsors/forms.py +++ b/sponsors/forms.py @@ -47,7 +47,11 @@ class Meta: ) -class SponsorshiptBenefitsForm(forms.Form): +class SponsorshipsBenefitsForm(forms.Form): + """ + Form to enable user to select packages, benefits and add-ons during + the sponsorship application submission. + """ package = forms.ModelChoiceField( queryset=SponsorshipPackage.objects.list_advertisables(), widget=forms.RadioSelect(), @@ -58,6 +62,10 @@ class SponsorshiptBenefitsForm(forms.Form): required=False, queryset=SponsorshipBenefit.objects.add_ons().select_related("program"), ) + a_la_carte_benefits = PickSponsorshipBenefitsField( + required=False, + queryset=SponsorshipBenefit.objects.a_la_carte().select_related("program"), + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -89,18 +97,31 @@ def benefits_conflicts(self): conflicts[benefit.id] = list(benefits_conflicts) return conflicts - def get_benefits(self, cleaned_data=None, include_add_ons=False): + def get_benefits(self, cleaned_data=None, include_add_ons=False, include_a_la_carte=False): cleaned_data = cleaned_data or self.cleaned_data benefits = list( chain(*(cleaned_data.get(bp.name) for bp in self.benefits_programs)) ) - add_ons = cleaned_data.get("add_ons_benefits") - if include_add_ons and add_ons: + add_ons = cleaned_data.get("add_ons_benefits", []) + if include_add_ons: benefits.extend([b for b in add_ons]) + a_la_carte = cleaned_data.get("a_la_carte_benefits", []) + if include_a_la_carte: + benefits.extend([b for b in a_la_carte]) return benefits def get_package(self): - return self.cleaned_data.get("package") + pkg = self.cleaned_data.get("package") + + pkg_benefits = self.get_benefits(include_add_ons=True) + a_la_carte = self.cleaned_data.get("a_la_carte_benefits") + if not pkg_benefits and a_la_carte: # a la carte only + pkg, _ = SponsorshipPackage.objects.get_or_create( + slug="a-la-carte-only", + defaults={"name": "A La Carte Only", "sponsorship_amount": 0}, + ) + + return pkg def _clean_benefits(self, cleaned_data): """ @@ -110,11 +131,17 @@ def _clean_benefits(self, cleaned_data): - benefit with no capacity, except if soft """ package = cleaned_data.get("package") - benefits = self.get_benefits(cleaned_data) - if not benefits: + benefits = self.get_benefits(cleaned_data, include_add_ons=True) + a_la_carte = cleaned_data.get("a_la_carte_benefits") + + if not benefits and not a_la_carte: raise forms.ValidationError( _("You have to pick a minimum number of benefits.") ) + elif benefits and not package: + raise forms.ValidationError( + _("You must pick a package to include the selected benefits.") + ) benefits_ids = [b.id for b in benefits] for benefit in benefits: @@ -408,6 +435,8 @@ def save(self, commit=True): self.instance.name = benefit.name self.instance.description = benefit.description self.instance.program = benefit.program + self.instance.added_by_user = self.instance.added_by_user or benefit.a_la_carte + self.instance.a_la_carte = benefit.a_la_carte if commit: self.instance.save() @@ -611,3 +640,22 @@ def update_assets(self): @property def has_input(self): return bool(self.fields) + + +class SponsorshipBenefitAdminForm(forms.ModelForm): + + class Meta: + model = SponsorshipBenefit + fields = "__all__" + + def clean(self): + cleaned_data = super().clean() + a_la_carte = cleaned_data.get("a_la_carte") + packages = cleaned_data.get("packages") + + # a la carte benefit cannot be associated with a package + if a_la_carte and packages: + error = "À la carte benefits must not belong to any package." + raise forms.ValidationError(error) + + return cleaned_data diff --git a/sponsors/migrations/0063_auto_20211220_1422.py b/sponsors/migrations/0063_auto_20211220_1422.py new file mode 100644 index 000000000..a0164756c --- /dev/null +++ b/sponsors/migrations/0063_auto_20211220_1422.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.24 on 2021-12-20 14:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0062_auto_20211111_1529'), + ] + + operations = [ + migrations.AddField( + model_name='sponsorshipbenefit', + name='a_la_carte', + field=models.BooleanField(default=False, help_text='À la carte benefits can be selected without the need of a package.', verbose_name='À La Carte'), + ), + migrations.AlterField( + model_name='requiredtextasset', + name='label', + field=models.CharField(help_text="What's the title used to display the text input to the sponsor?", max_length=256), + ), + migrations.AlterField( + model_name='requiredtextassetconfiguration', + name='label', + field=models.CharField(help_text="What's the title used to display the text input to the sponsor?", max_length=256), + ), + ] diff --git a/sponsors/migrations/0064_sponsorshippackage_slug.py b/sponsors/migrations/0064_sponsorshippackage_slug.py new file mode 100644 index 000000000..bf14023b4 --- /dev/null +++ b/sponsors/migrations/0064_sponsorshippackage_slug.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2021-12-23 13:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0063_auto_20211220_1422'), + ] + + operations = [ + migrations.AddField( + model_name='sponsorshippackage', + name='slug', + field=models.SlugField(default='', help_text='Internal identifier used to reference this package.'), + ), + ] diff --git a/sponsors/migrations/0065_auto_20211223_1309.py b/sponsors/migrations/0065_auto_20211223_1309.py new file mode 100644 index 000000000..b6e900b4e --- /dev/null +++ b/sponsors/migrations/0065_auto_20211223_1309.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.24 on 2021-12-23 13:09 + +from django.db import migrations +from django.utils.text import slugify + + +def populate_packages_slugs(apps, schema_editor): + SponsorshipPackage = apps.get_model("sponsors", "SponsorshipPackage") + qs = SponsorshipPackage.objects.filter(slug="") + for pkg in qs: + pkg.slug = slugify(pkg.name) + pkg.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0064_sponsorshippackage_slug'), + ] + + operations = [ + migrations.RunPython(populate_packages_slugs, migrations.RunPython.noop) + ] diff --git a/sponsors/migrations/0066_auto_20211223_1318.py b/sponsors/migrations/0066_auto_20211223_1318.py new file mode 100644 index 000000000..6fb637f99 --- /dev/null +++ b/sponsors/migrations/0066_auto_20211223_1318.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2021-12-23 13:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0065_auto_20211223_1309'), + ] + + operations = [ + migrations.AlterField( + model_name='sponsorshippackage', + name='slug', + field=models.SlugField(help_text='Internal identifier used to reference this package.'), + ), + ] diff --git a/sponsors/migrations/0067_sponsorbenefit_a_la_carte.py b/sponsors/migrations/0067_sponsorbenefit_a_la_carte.py new file mode 100644 index 000000000..cd3b98d4e --- /dev/null +++ b/sponsors/migrations/0067_sponsorbenefit_a_la_carte.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2021-12-24 14:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0066_auto_20211223_1318'), + ] + + operations = [ + migrations.AddField( + model_name='sponsorbenefit', + name='a_la_carte', + field=models.BooleanField(blank=True, default=False, verbose_name='Added as a la carte benefit?'), + ), + ] diff --git a/sponsors/models/managers.py b/sponsors/models/managers.py index 2eddb76cb..13716db49 100644 --- a/sponsors/models/managers.py +++ b/sponsors/models/managers.py @@ -80,12 +80,15 @@ def without_conflicts(self): return self.filter(conflicts__isnull=True) def add_ons(self): - return self.annotate(num_packages=Count("packages")).filter(num_packages=0) + return self.annotate(num_packages=Count("packages")).filter(num_packages=0, a_la_carte=False) + + def a_la_carte(self): + return self.filter(a_la_carte=True) def with_packages(self): return ( self.annotate(num_packages=Count("packages")) - .exclude(num_packages=0) + .exclude(Q(num_packages=0) | Q(a_la_carte=True)) .order_by("-num_packages", "order") ) diff --git a/sponsors/models/sponsors.py b/sponsors/models/sponsors.py index a3eeb83c0..8cd7f26e9 100644 --- a/sponsors/models/sponsors.py +++ b/sponsors/models/sponsors.py @@ -206,6 +206,9 @@ class SponsorBenefit(OrderedModel): added_by_user = models.BooleanField( blank=True, default=False, verbose_name="Added by user?" ) + a_la_carte = models.BooleanField( + blank=True, default=False, verbose_name="Added as a la carte benefit?" + ) def __str__(self): if self.program is not None: @@ -218,6 +221,8 @@ def features(self): @classmethod def new_copy(cls, benefit, **kwargs): + kwargs["added_by_user"] = kwargs.get("added_by_user") or benefit.a_la_carte + kwargs["a_la_carte"] = benefit.a_la_carte sponsor_benefit = cls.objects.create( sponsorship_benefit=benefit, program_name=benefit.program.name, diff --git a/sponsors/models/sponsorship.py b/sponsors/models/sponsorship.py index 44ac484aa..d41fe5625 100644 --- a/sponsors/models/sponsorship.py +++ b/sponsors/models/sponsorship.py @@ -38,6 +38,8 @@ class SponsorshipPackage(OrderedModel): logo_dimension = models.PositiveIntegerField(default=175, blank=True, help_text="Internal value used to control " "logos dimensions at sponsors " "page") + slug = models.SlugField(db_index=True, blank=False, null=False, help_text="Internal identifier used " + "to reference this package.") def __str__(self): return self.name @@ -367,6 +369,11 @@ class SponsorshipBenefit(OrderedModel): verbose_name="Benefit is unavailable", help_text="If selected, this benefit will not be available to applicants.", ) + a_la_carte = models.BooleanField( + default=False, + verbose_name="À La Carte", + help_text="À la carte benefits can be selected without the need of a package.", + ) # Internal legal_clauses = models.ManyToManyField( diff --git a/sponsors/tests/test_forms.py b/sponsors/tests/test_forms.py index dfaccbca9..8e960e71d 100644 --- a/sponsors/tests/test_forms.py +++ b/sponsors/tests/test_forms.py @@ -4,7 +4,7 @@ from django.test import TestCase from sponsors.forms import ( - SponsorshiptBenefitsForm, + SponsorshipsBenefitsForm, SponsorshipApplicationForm, Sponsor, SponsorContactForm, @@ -13,15 +13,15 @@ SponsorBenefit, Sponsorship, SponsorshipsListForm, - SendSponsorshipNotificationForm, SponsorRequiredAssetsForm, + SendSponsorshipNotificationForm, SponsorRequiredAssetsForm, SponsorshipBenefitAdminForm, ) from sponsors.models import SponsorshipBenefit, SponsorContact, RequiredTextAssetConfiguration, \ - RequiredImgAssetConfiguration, ImgAsset, RequiredTextAsset + RequiredImgAssetConfiguration, ImgAsset, RequiredTextAsset, SponsorshipPackage from .utils import get_static_image_file_as_upload from ..models.enums import AssetsRelatedTo -class SponsorshiptBenefitsFormTests(TestCase): +class SponsorshipsBenefitsFormTests(TestCase): def setUp(self): self.psf = baker.make("sponsors.SponsorshipProgram", name="PSF") self.wk = baker.make("sponsors.SponsorshipProgram", name="Working Group") @@ -38,8 +38,11 @@ def setUp(self): # packages without associated packages self.add_ons = baker.make(SponsorshipBenefit, program=self.psf, _quantity=2) - def test_benefits_organized_by_program(self): - form = SponsorshiptBenefitsForm() + # a la carte benefits + self.a_la_carte = baker.make(SponsorshipBenefit, program=self.psf, a_la_carte=True, _quantity=2) + + def test_specific_field_to_select_add_ons(self): + form = SponsorshipsBenefitsForm() choices = list(form.fields["add_ons_benefits"].choices) @@ -47,8 +50,8 @@ def test_benefits_organized_by_program(self): for benefit in self.add_ons: self.assertIn(benefit.id, [c[0] for c in choices]) - def test_specific_field_to_select_add_ons(self): - form = SponsorshiptBenefitsForm() + def test_benefits_organized_by_program(self): + form = SponsorshipsBenefitsForm() field1, field2 = sorted(form.benefits_programs, key=lambda f: f.name) @@ -66,31 +69,59 @@ def test_specific_field_to_select_add_ons(self): for benefit in self.program_2_benefits: self.assertIn(benefit.id, [c[0] for c in choices]) + def test_specific_field_to_select_a_la_carte_benefits(self): + form = SponsorshipsBenefitsForm() + + choices = list(form.fields["a_la_carte_benefits"].choices) + + self.assertEqual(len(self.a_la_carte), len(choices)) + for benefit in self.a_la_carte: + self.assertIn(benefit.id, [c[0] for c in choices]) + def test_package_list_only_advertisable_ones(self): ads_pkgs = baker.make('SponsorshipPackage', advertise=True, _quantity=2) baker.make('SponsorshipPackage', advertise=False) - form = SponsorshiptBenefitsForm() + form = SponsorshipsBenefitsForm() field = form.fields.get("package") self.assertEqual(3, field.queryset.count()) def test_invalidate_form_without_benefits(self): - form = SponsorshiptBenefitsForm(data={}) + form = SponsorshipsBenefitsForm(data={}) self.assertFalse(form.is_valid()) self.assertIn("__all__", form.errors) - form = SponsorshiptBenefitsForm( - data={"benefits_psf": [self.program_1_benefits[0].id]} + form = SponsorshipsBenefitsForm( + data={"benefits_psf": [self.program_1_benefits[0].id], "package": self.package.id} ) self.assertTrue(form.is_valid()) + def test_validate_form_without_package_but_with_a_la_carte_benefits(self): + benefit = self.a_la_carte[0] + form = SponsorshipsBenefitsForm( + data={"a_la_carte_benefits": [benefit.id]} + ) + self.assertTrue(form.is_valid()) + self.assertEqual([], form.get_benefits()) + self.assertEqual([benefit], form.get_benefits(include_a_la_carte=True)) + + def test_should_not_validate_form_without_package_with_add_ons_and_a_la_carte_benefits(self): + data = { + "a_la_carte_benefits": [self.a_la_carte[0]], + "add_ons_benefits": [self.add_ons[0]], + } + + form = SponsorshipsBenefitsForm(data=data) + + self.assertFalse(form.is_valid()) + def test_benefits_conflicts_helper_property(self): benefit_1, benefit_2 = baker.make("sponsors.SponsorshipBenefit", _quantity=2) benefit_1.conflicts.add(*self.program_1_benefits) benefit_2.conflicts.add(*self.program_2_benefits) - form = SponsorshiptBenefitsForm() + form = SponsorshipsBenefitsForm() map = form.benefits_conflicts # conflicts are symmetrical relationships @@ -113,12 +144,12 @@ def test_invalid_form_if_any_conflict(self): benefit_1.conflicts.add(*self.program_1_benefits) self.package.benefits.add(benefit_1) - data = {"benefits_psf": [b.id for b in self.program_1_benefits]} - form = SponsorshiptBenefitsForm(data=data) + data = {"benefits_psf": [b.id for b in self.program_1_benefits], "package": self.package.id} + form = SponsorshipsBenefitsForm(data=data) self.assertTrue(form.is_valid()) data["benefits_working_group"] = [benefit_1.id] - form = SponsorshiptBenefitsForm(data=data) + form = SponsorshipsBenefitsForm(data=data) self.assertFalse(form.is_valid()) self.assertIn( "The application has 1 or more benefits that conflicts.", @@ -129,8 +160,9 @@ def test_get_benefits_from_cleaned_data(self): benefit = self.program_1_benefits[0] data = {"benefits_psf": [benefit.id], - "add_ons_benefits": [b.id for b in self.add_ons]} - form = SponsorshiptBenefitsForm(data=data) + "add_ons_benefits": [b.id for b in self.add_ons], + "package": self.package.id} + form = SponsorshipsBenefitsForm(data=data) self.assertTrue(form.is_valid()) benefits = form.get_benefits() @@ -148,10 +180,10 @@ def test_package_only_benefit_without_package_should_not_validate(self): data = {"benefits_psf": [self.program_1_benefits[0]]} - form = SponsorshiptBenefitsForm(data=data) + form = SponsorshipsBenefitsForm(data=data) self.assertFalse(form.is_valid()) self.assertIn( - "The application has 1 or more package only benefits and no sponsor package.", + "You must pick a package to include the selected benefits.", form.errors["__all__"], ) @@ -165,7 +197,7 @@ def test_package_only_benefit_with_wrong_package_should_not_validate(self): "package": baker.make("sponsors.SponsorshipPackage", advertise=True).id, # other package } - form = SponsorshiptBenefitsForm(data=data) + form = SponsorshipsBenefitsForm(data=data) self.assertFalse(form.is_valid()) self.assertIn( "The application has 1 or more package only benefits but wrong sponsor package.", @@ -176,15 +208,15 @@ def test_package_only_benefit_with_wrong_package_should_not_validate(self): "benefits_psf": [self.program_1_benefits[0]], "package": package.id, } - form = SponsorshiptBenefitsForm(data=data) + form = SponsorshipsBenefitsForm(data=data) self.assertTrue(form.is_valid()) def test_benefit_with_no_capacity_should_not_validate(self): SponsorshipBenefit.objects.all().update(capacity=0) - data = {"benefits_psf": [self.program_1_benefits[0]]} + data = {"benefits_psf": [self.program_1_benefits[0]], "package": self.package.id} - form = SponsorshiptBenefitsForm(data=data) + form = SponsorshipsBenefitsForm(data=data) self.assertFalse(form.is_valid()) self.assertIn( "The application has 1 or more benefits with no capacity.", @@ -194,10 +226,38 @@ def test_benefit_with_no_capacity_should_not_validate(self): def test_benefit_with_soft_capacity_should_validate(self): SponsorshipBenefit.objects.all().update(capacity=0, soft_capacity=True) - data = {"benefits_psf": [self.program_1_benefits[0]]} + data = {"benefits_psf": [self.program_1_benefits[0]], "package": self.package.id} + + form = SponsorshipsBenefitsForm(data=data) + self.assertTrue(form.is_valid()) + + def test_get_package_return_selected_package(self): + data = {"benefits_psf": [self.program_1_benefits[0]], "package": self.package.id} + form = SponsorshipsBenefitsForm(data=data) + self.assertTrue(form.is_valid()) + self.assertEqual(self.package, form.get_package()) - form = SponsorshiptBenefitsForm(data=data) + def test_get_package_get_or_create_a_la_carte_only_package(self): + data = {"a_la_carte_benefits": [self.a_la_carte[0].id]} + form = SponsorshipsBenefitsForm(data=data) self.assertTrue(form.is_valid()) + self.assertEqual(1, SponsorshipPackage.objects.count()) + + # should create package if it doesn't exist yet + package = form.get_package() + self.assertEqual("A La Carte Only", package.name) + self.assertEqual("a-la-carte-only", package.slug) + self.assertEqual(175, package.logo_dimension) + self.assertEqual(0, package.sponsorship_amount) + self.assertFalse(package.advertise) + self.assertEqual(2, SponsorshipPackage.objects.count()) + + # re-use previously created package for subsequent applications + data = {"a_la_carte_benefits": [self.a_la_carte[0].id]} + form = SponsorshipsBenefitsForm(data=data) + self.assertTrue(form.is_valid()) + self.assertEqual(package, form.get_package()) + self.assertEqual(2, SponsorshipPackage.objects.count()) class SponsorshipApplicationFormTests(TestCase): @@ -451,7 +511,7 @@ def test_update_existing_sponsor_benefit(self): sponsorship=self.sponsorship, sponsorship_benefit=self.benefit, ) - new_benefit = baker.make(SponsorshipBenefit) + new_benefit = baker.make(SponsorshipBenefit, a_la_carte=True) self.data["sponsorship_benefit"] = new_benefit.pk form = SponsorBenefitAdminInlineForm(data=self.data, instance=sponsor_benefit) @@ -466,6 +526,8 @@ def test_update_existing_sponsor_benefit(self): self.assertEqual(sponsor_benefit.description, new_benefit.description) self.assertEqual(sponsor_benefit.program, new_benefit.program) self.assertEqual(sponsor_benefit.benefit_internal_value, 200) + self.assertTrue(sponsor_benefit.added_by_user) + self.assertTrue(sponsor_benefit.a_la_carte) def test_do_not_update_sponsorship_if_it_doesn_change(self): sponsor_benefit = baker.make( @@ -677,3 +739,26 @@ def test_load_initial_from_assets_and_force_field_if_previous_Data(self): def test_raise_error_if_form_initialized_without_instance(self): self.assertRaises(TypeError, SponsorRequiredAssetsForm) + + +class SponsorshipBenefitAdminFormTests(TestCase): + + def setUp(self): + self.program = baker.make("sponsors.SponsorshipProgram") + + def test_required_fields(self): + required = {"name", "program"} + form = SponsorshipBenefitAdminForm(data={}) + self.assertFalse(form.is_valid()) + self.assertEqual(set(form.errors), required) + + def test_a_la_carte_benefit_cannot_have_package(self): + data = {"name": "benefit", "program": self.program.pk, "a_la_carte": True} + form = SponsorshipBenefitAdminForm(data=data) + self.assertTrue(form.is_valid()) + + package = baker.make("sponsors.SponsorshipPackage") + data["packages"] = [package.pk] + form = SponsorshipBenefitAdminForm(data=data) + self.assertFalse(form.is_valid()) + self.assertIn("__all__", form.errors) diff --git a/sponsors/tests/test_managers.py b/sponsors/tests/test_managers.py index b88c64d92..d1f46555d 100644 --- a/sponsors/tests/test_managers.py +++ b/sponsors/tests/test_managers.py @@ -5,7 +5,7 @@ from django.test import TestCase from ..models import Sponsorship, SponsorBenefit, LogoPlacement, TieredQuantity, RequiredTextAsset, RequiredImgAsset, \ - BenefitFeature + BenefitFeature, SponsorshipPackage, SponsorshipBenefit from sponsors.models.enums import LogoPlacementChoices, PublisherChoices @@ -135,3 +135,23 @@ def test_filter_only_for_required_assets(self): self.assertEqual(qs.count(), 2) self.assertIn(text_asset, qs) + + +class SponsorshipBenefitManagerTests(TestCase): + + def setUp(self): + package = baker.make(SponsorshipPackage) + self.regular_benefit = baker.make(SponsorshipBenefit) + self.regular_benefit.packages.add(package) + self.add_on = baker.make(SponsorshipBenefit) + self.a_la_carte = baker.make(SponsorshipBenefit, a_la_carte=True) + + def test_add_ons_queryset(self): + qs = SponsorshipBenefit.objects.add_ons() + self.assertEqual(1, qs.count()) + self.assertIn(self.add_on, qs) + + def test_a_la_carte_queryset(self): + qs = SponsorshipBenefit.objects.a_la_carte() + self.assertEqual(1, qs.count()) + self.assertIn(self.a_la_carte, qs) diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 7d019814b..5bbb12cf5 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -622,6 +622,16 @@ def test_sponsor_benefit_name_for_display(self): ) self.assertEqual(sponsor_benefit.name_for_display, f"{name} (10)") + def test_sponsor_benefit_from_a_la_carte_one(self): + self.sponsorship_benefit.a_la_carte = True + self.sponsorship_benefit.save() + sponsor_benefit = SponsorBenefit.new_copy( + self.sponsorship_benefit, sponsorship=self.sponsorship + ) + + self.assertTrue(sponsor_benefit.added_by_user) + self.assertTrue(sponsor_benefit.a_la_carte) + ########### # Email notification tests class SponsorEmailNotificationTemplateTests(TestCase): diff --git a/sponsors/tests/test_views.py b/sponsors/tests/test_views.py index 8a77fb7af..0440dc134 100644 --- a/sponsors/tests/test_views.py +++ b/sponsors/tests/test_views.py @@ -18,7 +18,7 @@ Sponsorship, ) from sponsors.forms import ( - SponsorshiptBenefitsForm, + SponsorshipsBenefitsForm, SponsorshipApplicationForm, ) @@ -42,6 +42,9 @@ def setUp(self): self.add_on_benefits = baker.make( SponsorshipBenefit, program=self.psf, _quantity=2 ) + self.a_la_carte_benefits = baker.make( + SponsorshipBenefit, program=self.psf, _quantity=2, a_la_carte=True, + ) self.user = baker.make(settings.AUTH_USER_MODEL, is_staff=True, is_active=True) self.client.force_login(self.user) @@ -52,6 +55,7 @@ def setUp(self): "benefits_psf": [b.id for b in self.program_1_benefits], "benefits_working_group": [b.id for b in self.program_2_benefits], "add_ons_benefits": [b.id for b in self.add_on_benefits], + "a_la_carte_benefits": [b.id for b in self.a_la_carte_benefits], "package": self.package.id, } @@ -69,7 +73,7 @@ def test_display_template_with_form_and_context(self): self.assertEqual(r.status_code, 200) self.assertTemplateUsed(r, "sponsors/sponsorship_benefits_form.html") - self.assertIsInstance(r.context["form"], SponsorshiptBenefitsForm) + self.assertIsInstance(r.context["form"], SponsorshipsBenefitsForm) self.assertEqual(r.context["benefit_model"], SponsorshipBenefit) self.assertEqual(4, packages.count()) self.assertIn(psf_package, packages) @@ -84,7 +88,7 @@ def test_display_form_with_errors_if_invalid_post(self): r = self.client.post(self.url, {}) form = r.context["form"] - self.assertIsInstance(form, SponsorshiptBenefitsForm) + self.assertIsInstance(form, SponsorshipsBenefitsForm) self.assertTrue(form.errors) def test_valid_post_redirect_user_to_next_form_step_and_save_info_in_cookies(self): @@ -132,10 +136,27 @@ def test_invalidate_post_even_if_valid_data_but_user_does_not_allow_cookies(self r = self.client.post(self.url, data=self.data) form = r.context["form"] - self.assertIsInstance(form, SponsorshiptBenefitsForm) + self.assertIsInstance(form, SponsorshipsBenefitsForm) msg = "You must allow cookies from python.org to proceed." self.assertEqual(form.non_field_errors(), [msg]) + def test_valid_only_with_a_la_carte(self): + self.populate_test_cookie() + + # update data dict to have only a la carte + self.data["benefits_psf"] = [] + self.data["benefits_working_group"] = [] + self.data["add_ons_benefits"] = [] + self.data["package"] = "" + + response = self.client.post(self.url, data=self.data) + + self.assertRedirects(response, reverse("new_sponsorship_application")) + cookie_value = json.loads( + response.client.cookies["sponsorship_selected_benefits"].value + ) + self.assertEqual(self.data, cookie_value) + class NewSponsorshipApplicationViewTests(TestCase): url = reverse_lazy("new_sponsorship_application") @@ -155,12 +176,15 @@ def setUp(self): # packages without associated packages self.add_on = baker.make(SponsorshipBenefit) + # a la carte benefit + self.a_la_carte = baker.make(SponsorshipBenefit, a_la_carte=True) self.client.cookies["sponsorship_selected_benefits"] = json.dumps( { "package": self.package.id, "benefits_psf": [b.id for b in self.program_1_benefits], "add_ons_benefits": [self.add_on.id], + "a_la_carte_benefits": [self.a_la_carte.id], } ) self.data = { @@ -183,6 +207,7 @@ def setUp(self): def test_display_template_with_form_and_context_without_add_ons(self): self.add_on.delete() + self.a_la_carte.delete() r = self.client.get(self.url) self.assertEqual(r.status_code, 200) @@ -290,8 +315,8 @@ def test_create_new_sponsorship(self): ) sponsorship = Sponsorship.objects.get(sponsor__name="CompanyX") self.assertTrue(sponsorship.benefits.exists()) - # 3 benefits + 1 add-on - self.assertEqual(4, sponsorship.benefits.count()) + # 3 benefits + 1 add-on + 1 a la carte + self.assertEqual(5, sponsorship.benefits.count()) self.assertTrue(sponsorship.level_name) self.assertTrue(sponsorship.submited_by, self.user) self.assertEqual( @@ -332,3 +357,25 @@ def test_redirect_user_back_to_benefits_selection_if_post_without_valid_set_of_b self.client.cookies["sponsorship_selected_benefits"] = "invalid" r = self.client.post(self.url, data=self.data) self.assertRedirects(r, reverse("select_sponsorship_application_benefits")) + + def test_create_new_a_la_carte_sponsorship(self): + self.assertFalse(Sponsor.objects.exists()) + self.client.cookies["sponsorship_selected_benefits"] = json.dumps( + { + "package": "", + "benefits_psf": [], + "add_ons_benefits": [], + "a_la_carte_benefits": [self.a_la_carte.id], + } + ) + + r = self.client.post(self.url, data=self.data) + self.assertEqual(r.context["sponsorship"].sponsor.name, "CompanyX") + self.assertEqual(r.context["notified"], ["bernardo@companyemail.com"]) + + sponsorship = Sponsorship.objects.get(sponsor__name="CompanyX") + self.assertTrue(sponsorship.benefits.exists()) + # only the a la carte + self.assertEqual(1, sponsorship.benefits.count()) + self.assertEqual(self.a_la_carte, sponsorship.benefits.get().sponsorship_benefit) + self.assertEqual(sponsorship.package.slug, "a-la-carte-only") diff --git a/sponsors/views.py b/sponsors/views.py index 766649647..2afeadb4d 100644 --- a/sponsors/views.py +++ b/sponsors/views.py @@ -17,11 +17,11 @@ from sponsors import cookies from sponsors import use_cases -from sponsors.forms import SponsorshiptBenefitsForm, SponsorshipApplicationForm +from sponsors.forms import SponsorshipsBenefitsForm, SponsorshipApplicationForm class SelectSponsorshipApplicationBenefitsView(FormView): - form_class = SponsorshiptBenefitsForm + form_class = SponsorshipsBenefitsForm template_name = "sponsors/sponsorship_benefits_form.html" def get_context_data(self, *args, **kwargs): @@ -68,13 +68,14 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def _set_form_data_cookie(self, form, response): + pkg = form.cleaned_data.get("package", "") data = { - "package": "" if not form.get_package() else form.get_package().id, + "package": "" if not pkg else pkg.id, } for fname, benefits in [ (f, v) for f, v in form.cleaned_data.items() - if f.startswith("benefits_") or f == 'add_ons_benefits' + if f.startswith("benefits_") or f in ['add_ons_benefits', 'a_la_carte_benefits'] ]: data[fname] = sorted(b.id for b in benefits) @@ -144,7 +145,7 @@ def get_context_data(self, *args, **kwargs): @transaction.atomic def form_valid(self, form): - benefits_form = SponsorshiptBenefitsForm(data=self.benefits_data) + benefits_form = SponsorshipsBenefitsForm(data=self.benefits_data) if not benefits_form.is_valid(): return self._redirect_back_to_benefits() @@ -154,7 +155,7 @@ def form_valid(self, form): sponsorship = uc.execute( self.request.user, sponsor, - benefits_form.get_benefits(include_add_ons=True), + benefits_form.get_benefits(include_add_ons=True, include_a_la_carte=True), benefits_form.get_package(), request=self.request, ) diff --git a/static/img/sponsors/title-1.png b/static/img/sponsors/title-1.png deleted file mode 100644 index d84fdf83f1735259742638a3267309f073368ce0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 953 zcmV;q14jIbP)X2xKww>T6)e&?4B%2LLr?WNGpV>PSp1Z70SSi4V|j41Jh^mA()l zF<^w~!eY?@q{O+Lb7MzN+oX25N$j6w_d33k|M&X%oO{l#fCl8@j7{eWNI3}sIz$l? zR;-Ip_dP(+0T=aPUrf0!{u6;7QG#V-mV}n*vWT<@c;JE5g4ewcB(?Z(mKUjMLm&J$ zs%Zz2SiJoF$U?og3caFFs#!x}q$+N_^^$Zql5~>$_sb-F7yT5j9)uD^kVL@gjZg0(ZzTJVL3y&3TKL8 zoN3XT`7m5g&AbZv*~cujbSm-9S?_c7)Q2s1ZJQgWx)gGbT#{D&<2v@f z9IJM5)WqcVQD~!J%+lF4!%G{Q@Q(gsZT$!uqO|6RktFbflGHa0ZjJii9-=O5Lsy!_fSo4!CPCy8g1={Xy0 z!@hG7;Dc~pee17y%9Y7%goQjJQDi@#)#X5Ugs>8&fmtQz;+yr8JmMSVa(v_x>Cw!K zJ>(MMndEY`i09yBKl{+S{!VM0h;>>r>^yw0+T|x#P=0z98sLSQ*oUSiWn6F^4pvdX z(iXU6W6uy{n=VupH%Cb6iSeCxW_Uq08j+~P)(PwK1+K1PK)8Hr`gvh&AXm_2BNQK+ zj*EQYvdM5G7GXsdc|bOtsM!*qi`l1dGn#d#eQi?@xS*L;&*LL#v{^@+cq-<2To*?+ zag@For&g7!m?`BlGKF-+*w)I)Uvpib&-Kno*JMPDZ2?#&oSR9BTborb z>|=0Vh@Vpy$m;9{*-8#|FN30&iNj~SwoV9V \ No newline at end of file diff --git a/static/img/sponsors/title-2.png b/static/img/sponsors/title-2.png deleted file mode 100644 index abbceddba5cdf6aa099bc6b5676ad6f39bc37bb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1055 zcmV+)1mOFLP)l*nT72qY1!GGbiecV&3iL%5zv5KA2sNE2vVjX zKp&BSMAFlxOxJBd&<7SO)ip6}S=c869ijxw+87DVP&0vc5wO7n*{aTUI*_)-J66uq zp#gpH(wq~GLbfcPC^z;_Qr8jnv?y(@9;p#@n@O{+% z0mT*Dab4rBgh&S326l=`zV60*wmQ!XorEUdeE!83 zI`a?ueg3b5m{UK|ikqxlviHs+Gjh3#yb0#gr%L7dV;)R)5gN7yLZmFMRyw=3z zMb=hhVqnpNs^WGLQrcp0`EA^3P>n_eDzP>Fz4bH>5R1RI{wN*Vy2WuL<(t&fzov2*{-h7clDc*mYWeVSjteX-o#-*uG>mDv*%7qTv;q|VIKz< zh4?;MF}Rf5t7Gu@uq#HAYJ_>G)D{-}GI_Ewsx+hhH{ct$Z&JK9q5BOuWlnDb3PR`q zd%%OJR5Rc~P#FU4Q&y{4$oY0fyJ#tuIZq=6^)yrkjy|4x5O4B%ugtqP-geSPqEo?M Z{sUkWa+&jVr-J|h002ovPDHLkV1ic%?=S!W diff --git a/static/img/sponsors/title-2.svg b/static/img/sponsors/title-2.svg new file mode 100644 index 000000000..1dbf3b8ab --- /dev/null +++ b/static/img/sponsors/title-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/sponsors/title-3.png b/static/img/sponsors/title-3.png deleted file mode 100644 index 95b9b0cb58af04fd97d40cc61a795dc8e2e95b9d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1138 zcmV-&1daQNP)E zBG8EXnmF`(7ErVygIs=Eoy}y>APRM21nWXC360TV1ht}&1$SgAKU8l=Y8LN~@_pL1 zAO}BLt4X1)J+4l^$OdU13ereVUmhUglh{b%=0=F|ea!s= zudaVQ<)16C<-&_p>A;#cPZ7m# z3gHa-fu6gPSnH=%BX67~q!MU03VM)yAn89>GKL@|&ML^yVQn0So{UrBsAu;;?Ws&k^w$|w~LvQuNh(fEckrDI9$eh_d*0+m@;>yh_iqO z<2`pEPElq!{cDx!6z4nrBwhrqv#N9a@Ur-s%Ly8}Kqa;yOub+9oR=qN{<)3_TBk^S zpSu_djnEk}@q%_;0iEJclVMfIXa-znZVfW3Gi_YTH$6~sv;w+d7k54JEiwAW*3(`f zE1j6-@?6jq%ecZg$QMeiqeMIvbFy3)M{{BueLhxAiA^z6>m-^AZL5y4N9AW*x<231 zZ$_%Fq*UivjB52ZwgNKUYQmXymx1c^Xq*fC*t{sj_Zgt{Ke%4Q;O}84ttiz9@4Kb8 zje=h$_mxMLO4R-a{O#3?6mM-vzX7Mr=}$mGsQ \ No newline at end of file diff --git a/static/img/sponsors/title-4.png b/static/img/sponsors/title-4.png deleted file mode 100644 index 52b578f2b792a1659ac4df346eee90ce62084580..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1027 zcmV+e1pNDnP)mXc<)K*Dzrq;Ts&$nbNl=egii z%gepgGJVbCtf|VJNN#D}x^xvUcT;)lF^ltp+(`Z&4Fd-r6%!qIOM3}J?R9G31x8H8AhAkrc&bM6Gq`o z+|Dx%8kV;+=QhGlxiVSL#16|_DL?!u0}Y*)_?p!N&Yu3E${khZhGLxxIZGkQcM4yQ zVIG$o=ZT>UNZh)Jz8Q$6>8LCAsS2N2+xms)^Se#g7GK{~3Ku`vfJYiM%i1Fhua_j9nPd=XJZRtqsPvZ2GO;zkH54Kcd?NwH6;Mk8F6*u0{z zJVF5Ie5fTU5nD&3AY;Q8Z@Z3;XrQypaKo`Y5IIBvS=H*zhWJQWpXO%W>P+*(tX$!O zW>_PSiAuG}JDS8(F~{J#IKqi$>2q*mNo*A}rCdg+keV3TeA{|8()IaBzZvPelMsF=>9$Ij+&qv;i6q?3krUjJl7gk zI^q2d_~)k+6mK>7{RW&er!xV$q4)nD@F2>S40sS!hJbfWYc&hG=2di^p2a({e+}MB xs \ No newline at end of file diff --git a/static/img/sponsors/title-5.svg b/static/img/sponsors/title-5.svg new file mode 100644 index 000000000..0400fb24b --- /dev/null +++ b/static/img/sponsors/title-5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/sponsors/title-6.svg b/static/img/sponsors/title-6.svg new file mode 100644 index 000000000..fa6c95925 --- /dev/null +++ b/static/img/sponsors/title-6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/sass/style.css b/static/sass/style.css index e92a2334f..c03d75f24 100644 --- a/static/sass/style.css +++ b/static/sass/style.css @@ -3842,32 +3842,32 @@ span.highlighted { display: none; } #select_sponsorship_benefits_container #benefitsTable .selected { background-color: transparent; } } -#select_sponsorship_benefits_container #add-on-benefits { +#select_sponsorship_benefits_container .custom-benefits { margin: 2em 0; padding: 0 2em; } - #select_sponsorship_benefits_container #add-on-benefits input { + #select_sponsorship_benefits_container .custom-benefits input { margin-bottom: 0.5em; } - #select_sponsorship_benefits_container #add-on-benefits .col-items { + #select_sponsorship_benefits_container .custom-benefits .col-items { min-height: 137px; background-color: #C4C4C4; margin: 0em 0.5em; padding: 1em 0.5em; width: 23%; color: #000000; } - #select_sponsorship_benefits_container #add-on-benefits .benefit-title { + #select_sponsorship_benefits_container .custom-benefits .benefit-title { color: #000000; margin: 0; padding: 0; } @media (max-width: 1200px) { - #select_sponsorship_benefits_container #add-on-benefits { + #select_sponsorship_benefits_container .custom-benefits { margin: 0; padding: 1em; } - #select_sponsorship_benefits_container #add-on-benefits input { + #select_sponsorship_benefits_container .custom-benefits input { margin-right: 1em; } - #select_sponsorship_benefits_container #add-on-benefits .row { + #select_sponsorship_benefits_container .custom-benefits .row { flex-flow: column wrap !important; width: 100%; } - #select_sponsorship_benefits_container #add-on-benefits .col-items { + #select_sponsorship_benefits_container .custom-benefits .col-items { background-color: transparent; flex-flow: row nowrap !important; width: 100%; @@ -3875,7 +3875,7 @@ span.highlighted { padding: 0; min-height: initial; align-items: baseline; } - #select_sponsorship_benefits_container #add-on-benefits .add-on-description { + #select_sponsorship_benefits_container .custom-benefits .add-on-description { text-align: left; padding-right: 1em; } } #select_sponsorship_benefits_container .submit-row { diff --git a/static/sass/style.scss b/static/sass/style.scss index 9f4f2f74c..9d7172aaf 100644 --- a/static/sass/style.scss +++ b/static/sass/style.scss @@ -2739,7 +2739,7 @@ $breakpoint-desktop: 1200px; }; } - #add-on-benefits { + .custom-benefits { margin: 2em 0; padding: 0 2em; diff --git a/templates/sponsors/sponsorship_benefits_form.html b/templates/sponsors/sponsorship_benefits_form.html index 1f9787d41..227abc307 100644 --- a/templates/sponsors/sponsorship_benefits_form.html +++ b/templates/sponsors/sponsorship_benefits_form.html @@ -17,7 +17,7 @@
- +

Select a Sponsorship Package

@@ -125,35 +125,76 @@

{{ benefit.name }}

{% endfor %}
-
- -
-

Select add-on benefits

- Available at any level of sponsorship + {% if form.fields.add_ons_benefits.queryset.exists %} +
+ +
+

Select add-on benefits

+ Available at any level of sponsorship +
-
-
-
- {% for benefit in form.fields.add_ons_benefits.queryset %} -
- -
- {{ benefit.program }} - - {{ benefit.name }} - {{ benefit.description }} +
+
+ {% for benefit in form.fields.add_ons_benefits.queryset %} +
+ +
+ {{ benefit.program }} - + {{ benefit.name }} + {{ benefit.description }} +
+ {% if forloop.counter|divisibleby:4 %} +
+
+ {% endif %} + {% endfor %}
- {% if forloop.counter|divisibleby:4 %}
-
- {% endif %} - {% endfor %} + {% endif %} + + {% if form.fields.a_la_carte_benefits.queryset.exists %} +
+ {% if not form.fields.add_ons_benefits.queryset.exists %} + + {% else %} + + {% endif %} +
+

Select a la carte benefits

+ Available to be selected without package +
-
+ +
+
+ {% for benefit in form.fields.a_la_carte_benefits.queryset %} +
+ +
+ {{ benefit.program }} - + {{ benefit.name }} + {{ benefit.description }} +
+
+ {% if forloop.counter|divisibleby:4 %} +
+
+ {% endif %} + {% endfor %} +
+
+ {% endif %}
- + {% if form.fields.add_ons_benefits.queryset.exists and form.fields.a_la_carte_benefits.queryset.exists %} + + {% elif form.fields.add_ons_benefits.queryset.exists or form.fields.a_la_carte_benefits.queryset.exists %} + + {% else %} + + {% endif %}

Submit your contact information

@@ -169,7 +210,13 @@

Submit your contact information

- + {% if form.fields.add_ons_benefits.queryset.exists and form.fields.a_la_carte_benefits.queryset.exists %} + + {% elif form.fields.add_ons_benefits.queryset.exists or form.fields.a_la_carte_benefits.queryset.exists %} + + {% else %} + + {% endif %}

PSF staff will reach out to confirm and finalize