From 1409df3740a65c7446a3f02ca5aeb477687a912b Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Mon, 10 Jan 2022 13:41:36 -0500 Subject: [PATCH 1/9] add missing migration --- sponsors/migrations/0068_auto_20220110_1841.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 sponsors/migrations/0068_auto_20220110_1841.py diff --git a/sponsors/migrations/0068_auto_20220110_1841.py b/sponsors/migrations/0068_auto_20220110_1841.py new file mode 100644 index 000000000..8149d57da --- /dev/null +++ b/sponsors/migrations/0068_auto_20220110_1841.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2022-01-10 18:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0067_sponsorbenefit_a_la_carte'), + ] + + operations = [ + migrations.AlterField( + model_name='sponsorship', + name='for_modified_package', + field=models.BooleanField(default=False, help_text="If true, it means the user customized the package's benefits. Changes are listed under section 'User Customizations'."), + ), + ] From 3c277a8b370b9083e636dcaf96566ce52dea16e7 Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Mon, 10 Jan 2022 16:52:02 -0500 Subject: [PATCH 2/9] WIP: "Provided Assets" from PSF to sponsors creates the ability to configure "Provided Assets" associated with a sponsorship that will be fulfilled by the PSF. The initial "ProvidedTextAsset" is intended to be used for PyCon US 2022 voucher codes, which will be unique to each voucher "type" and sponsorship level. Additionally, we will likely include "ProvidedFileAsset" that will be used for all sponsorships with Expo Hall benefits to share with them a common "Exhibitor Packet" --- sponsors/admin.py | 4 + .../migrations/0069_auto_20220110_2148.py | 53 +++++++++++ sponsors/models/__init__.py | 2 +- sponsors/models/benefits.py | 91 ++++++++++++++++--- sponsors/models/managers.py | 5 + templates/users/sponsorship_assets_view.html | 31 +++++++ templates/users/sponsorship_detail.html | 22 ++++- users/tests/test_views.py | 6 +- users/urls.py | 5 + users/views.py | 33 ++++++- 10 files changed, 231 insertions(+), 21 deletions(-) create mode 100644 sponsors/migrations/0069_auto_20220110_2148.py create mode 100644 templates/users/sponsorship_assets_view.html diff --git a/sponsors/admin.py b/sponsors/admin.py index c2541ab21..046f9e42f 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -67,6 +67,9 @@ class RequiredImgAssetConfigurationInline(StackedPolymorphicInline.Child): class RequiredTextAssetConfigurationInline(StackedPolymorphicInline.Child): model = RequiredTextAssetConfiguration + class ProvidedTextAssetConfigurationInline(StackedPolymorphicInline.Child): + model = ProvidedTextAssetConfiguration + model = BenefitFeatureConfiguration child_inlines = [ LogoPlacementConfigurationInline, @@ -74,6 +77,7 @@ class RequiredTextAssetConfigurationInline(StackedPolymorphicInline.Child): EmailTargetableConfigurationInline, RequiredImgAssetConfigurationInline, RequiredTextAssetConfigurationInline, + ProvidedTextAssetConfigurationInline, ] diff --git a/sponsors/migrations/0069_auto_20220110_2148.py b/sponsors/migrations/0069_auto_20220110_2148.py new file mode 100644 index 000000000..8492ce06b --- /dev/null +++ b/sponsors/migrations/0069_auto_20220110_2148.py @@ -0,0 +1,53 @@ +# Generated by Django 2.2.24 on 2022-01-10 21:48 + +from django.db import migrations, models +import django.db.models.deletion +import sponsors.models.benefits + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0068_auto_20220110_1841'), + ] + + operations = [ + migrations.CreateModel( + name='ProvidedTextAsset', + fields=[ + ('benefitfeature_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='sponsors.BenefitFeature')), + ('related_to', models.CharField(choices=[('sponsor', 'Sponsor'), ('sponsorship', 'Sponsorship')], help_text='To which instance (Sponsor or Sponsorship) should this asset relate to.', max_length=30, verbose_name='Related To')), + ('internal_name', models.CharField(db_index=True, help_text='Unique name used internally to control if the sponsor/sponsorship already has the asset', max_length=128, verbose_name='Internal Name')), + ('label', models.CharField(help_text="What's the title used to display the text input to the sponsor?", max_length=256)), + ('help_text', models.CharField(blank=True, default='', help_text='Any helper comment on how the input should be populated', max_length=256)), + ], + options={ + 'verbose_name': 'Provided Text', + 'verbose_name_plural': 'Provided Texts', + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=(sponsors.models.benefits.ProvidedAssetMixin, 'sponsors.benefitfeature', models.Model), + ), + migrations.CreateModel( + name='ProvidedTextAssetConfiguration', + fields=[ + ('benefitfeatureconfiguration_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='sponsors.BenefitFeatureConfiguration')), + ('related_to', models.CharField(choices=[('sponsor', 'Sponsor'), ('sponsorship', 'Sponsorship')], help_text='To which instance (Sponsor or Sponsorship) should this asset relate to.', max_length=30, verbose_name='Related To')), + ('internal_name', models.CharField(db_index=True, help_text='Unique name used internally to control if the sponsor/sponsorship already has the asset', max_length=128, verbose_name='Internal Name')), + ('label', models.CharField(help_text="What's the title used to display the text input to the sponsor?", max_length=256)), + ('help_text', models.CharField(blank=True, default='', help_text='Any helper comment on how the input should be populated', max_length=256)), + ], + options={ + 'verbose_name': 'Provided Text Configuration', + 'verbose_name_plural': 'Provided Text Configurations', + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=(sponsors.models.benefits.AssetConfigurationMixin, 'sponsors.benefitfeatureconfiguration', models.Model), + ), + migrations.AddConstraint( + model_name='providedtextassetconfiguration', + constraint=models.UniqueConstraint(fields=('internal_name',), name='uniq_provided_text_asset_cfg'), + ), + ] diff --git a/sponsors/models/__init__.py b/sponsors/models/__init__.py index d7d2759be..0fc61de22 100644 --- a/sponsors/models/__init__.py +++ b/sponsors/models/__init__.py @@ -10,6 +10,6 @@ from .benefits import BaseLogoPlacement, BaseTieredQuantity, BaseEmailTargetable, BenefitFeatureConfiguration, \ LogoPlacementConfiguration, TieredQuantityConfiguration, EmailTargetableConfiguration, BenefitFeature, \ LogoPlacement, EmailTargetable, TieredQuantity, RequiredImgAsset, RequiredImgAssetConfiguration, \ - RequiredTextAssetConfiguration, RequiredTextAsset + RequiredTextAssetConfiguration, RequiredTextAsset, ProvidedTextAssetConfiguration, ProvidedTextAsset from .sponsorship import Sponsorship, SponsorshipProgram, SponsorshipBenefit, Sponsorship, SponsorshipPackage from .contract import LegalClause, Contract, signed_contract_random_path diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index 0051a84c0..66a8000c1 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -56,7 +56,7 @@ class Meta: abstract = True -class BaseRequiredAsset(models.Model): +class BaseAsset(models.Model): ASSET_CLASS = None related_to = models.CharField( @@ -87,7 +87,17 @@ class Meta: abstract = True -class RequiredAssetConfigurationMixin: +class BaseRequiredAsset(BaseAsset): + class Meta: + abstract = True + + +class BaseProvidedAsset(BaseAsset): + class Meta: + abstract = True + + +class AssetConfigurationMixin: """ This class should be used to implement assets configuration. It's a mixin to updates the benefit feature creation to also @@ -97,7 +107,7 @@ class RequiredAssetConfigurationMixin: def create_benefit_feature(self, sponsor_benefit, **kwargs): if not self.ASSET_CLASS: raise NotImplementedError( - "Subclasses of RequiredAssetConfigurationMixin must define an ASSET_CLASS attribute.") + "Subclasses of AssetConfigurationMixin must define an ASSET_CLASS attribute.") # Super: BenefitFeatureConfiguration.create_benefit_feature benefit_feature = super().create_benefit_feature(sponsor_benefit, **kwargs) @@ -149,12 +159,25 @@ class Meta(BaseRequiredAsset.Meta): abstract = True -class RequiredAssetMixin: - """ - This class should be used to implement required assets. - It's a mixin to get the information submitted by the user - and which is stored in the related asset class. - """ +class BaseProvidedTextAsset(BaseProvidedAsset): + ASSET_CLASS = TextAsset + + label = models.CharField( + max_length=256, + help_text="What's the title used to display the text input to the sponsor?" + ) + help_text = models.CharField( + max_length=256, + help_text="Any helper comment on how the input should be populated", + default="", + blank=True + ) + + class Meta(BaseProvidedAsset.Meta): + abstract = True + + +class AssetMixin: def __related_asset(self): object = self.sponsor_benefit.sponsorship @@ -180,6 +203,28 @@ def user_edit_url(self): return url + f"?required_asset={self.pk}" + @property + def user_view_url(self): + url = reverse("users:view_provided_sponsorship_assets", args=[self.sponsor_benefit.sponsorship.pk]) + return url + f"?provided_asset={self.pk}" + +class RequiredAssetMixin(AssetMixin): + """ + This class should be used to implement required assets. + It's a mixin to get the information submitted by the user + and which is stored in the related asset class. + """ + pass + +class ProvidedAssetMixin(AssetMixin): + """ + This class should be used to implement provided assets. + It's a mixin to get the information submitted by the staff + and which is stored in the related asset class. + """ + pass + + ###################################################### # SponsorshipBenefit features configuration models class BenefitFeatureConfiguration(PolymorphicModel): @@ -303,7 +348,7 @@ def __str__(self): return f"Email targeatable configuration" -class RequiredImgAssetConfiguration(RequiredAssetConfigurationMixin, BaseRequiredImgAsset, BenefitFeatureConfiguration): +class RequiredImgAssetConfiguration(AssetConfigurationMixin, BaseRequiredImgAsset, BenefitFeatureConfiguration): class Meta(BaseRequiredImgAsset.Meta, BenefitFeatureConfiguration.Meta): verbose_name = "Require Image Configuration" verbose_name_plural = "Require Image Configurations" @@ -317,7 +362,7 @@ def benefit_feature_class(self): return RequiredImgAsset -class RequiredTextAssetConfiguration(RequiredAssetConfigurationMixin, BaseRequiredTextAsset, +class RequiredTextAssetConfiguration(AssetConfigurationMixin, BaseRequiredTextAsset, BenefitFeatureConfiguration): class Meta(BaseRequiredTextAsset.Meta, BenefitFeatureConfiguration.Meta): verbose_name = "Require Text Configuration" @@ -332,6 +377,21 @@ def benefit_feature_class(self): return RequiredTextAsset +class ProvidedTextAssetConfiguration(AssetConfigurationMixin, BaseProvidedTextAsset, + BenefitFeatureConfiguration): + class Meta(BaseProvidedTextAsset.Meta, BenefitFeatureConfiguration.Meta): + verbose_name = "Provided Text Configuration" + verbose_name_plural = "Provided Text Configurations" + constraints = [UniqueConstraint(fields=["internal_name"], name="uniq_provided_text_asset_cfg")] + + def __str__(self): + return f"Provided text configuration" + + @property + def benefit_feature_class(self): + return ProvidedTextAsset + + #################################### # SponsorBenefit features models class BenefitFeature(PolymorphicModel): @@ -420,3 +480,12 @@ def as_form_field(self, **kwargs): label = kwargs.pop("label", self.label) required = kwargs.pop("required", False) return forms.CharField(required=required, help_text=help_text, label=label, widget=forms.TextInput, **kwargs) + + +class ProvidedTextAsset(ProvidedAssetMixin, BaseProvidedTextAsset, BenefitFeature): + class Meta(BaseProvidedTextAsset.Meta, BenefitFeature.Meta): + verbose_name = "Provided Text" + verbose_name_plural = "Provided Texts" + + def __str__(self): + return f"Provided text" diff --git a/sponsors/models/managers.py b/sponsors/models/managers.py index 13716db49..a6735644e 100644 --- a/sponsors/models/managers.py +++ b/sponsors/models/managers.py @@ -107,3 +107,8 @@ def required_assets(self): from sponsors.models.benefits import RequiredAssetMixin required_assets_classes = RequiredAssetMixin.__subclasses__() return self.instance_of(*required_assets_classes).select_related("sponsor_benefit__sponsorship") + + def provided_assets(self): + from sponsors.models.benefits import ProvidedAssetMixin + provided_assets_classes = ProvidedAssetMixin.__subclasses__() + return self.instance_of(*provided_assets_classes).select_related("sponsor_benefit__sponsorship") diff --git a/templates/users/sponsorship_assets_view.html b/templates/users/sponsorship_assets_view.html new file mode 100644 index 000000000..3bb0d2d37 --- /dev/null +++ b/templates/users/sponsorship_assets_view.html @@ -0,0 +1,31 @@ +{% extends "users/base.html" %} +{% load widget_tweaks %} +{% load humanize pipeline %} + +{% block head %} + {% stylesheet 'font-awesome' %} +{% endblock %} + +{% block page_title %} + {{ sponsorship }} assets | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="psf signup default-page"{% endblock %} + +{% block main-nav_attributes %}psf-navigation{% endblock %} + +{% block user_content %} +
+

View assets for {{ sponsorship.sponsor }} sponsorship

+ + {% for asset in provided_assets %} +

{{ asset.sponsor_benefit }} benefit provides you with {{ asset.label }}:

+ {% if asset.polymorphic_ctype.name == "Provided Text" %} +
{{ asset.value }}
+ {% else %} + {{ asset.value }} + {% endif %} + {% endfor %} + +
+{% endblock %} diff --git a/templates/users/sponsorship_detail.html b/templates/users/sponsorship_detail.html index 7e83d3789..36d3e0961 100644 --- a/templates/users/sponsorship_detail.html +++ b/templates/users/sponsorship_detail.html @@ -87,13 +87,16 @@

Application Data

+ + +
{% if assets or fulfilled_assets %}

Required Assets

You've selected benefits which requires extra assets (logos, slides etc).


    - {% for asset in assets %} + {% for asset in required_assets %}
  • {{ asset.label }}: pending. Add asset.
  • {% endfor %} {% for asset in fulfilled_assets %} @@ -104,9 +107,22 @@

    Required Assets

    Or you can also click here to edit all the assets under the same page.
{% endif %} -
-
+ {% if provided_assets %} +
+

Provided Assets

+

Assets from the PSF related to your sponsorship.

+
+
    + {% for asset in provided_assets %} +
  • {{ asset.label }}: View asset.
  • + {% endfor %} +
+
+ Or you can also click here to view all the assets under the same page. +
+ {% endif %} +

Sponsorship Benefits

    diff --git a/users/tests/test_views.py b/users/tests/test_views.py index 376c0862c..02edc77c4 100644 --- a/users/tests/test_views.py +++ b/users/tests/test_views.py @@ -430,8 +430,8 @@ def test_list_assets(self): context = response.context self.assertEqual(response.status_code, 200) - self.assertEqual(1, len(context["assets"])) - self.assertIn(asset, context["assets"]) + self.assertEqual(1, len(context["required_assets"])) + self.assertIn(asset, context["required_assets"]) self.assertEqual(0, len(context["fulfilled_assets"])) def test_fulfilled_assets(self): @@ -445,7 +445,7 @@ def test_fulfilled_assets(self): context = response.context self.assertEqual(response.status_code, 200) - self.assertEqual(0, len(context["assets"])) + self.assertEqual(0, len(context["required_assets"])) self.assertEqual(1, len(context["fulfilled_assets"])) self.assertIn(asset, context["fulfilled_assets"]) diff --git a/users/urls.py b/users/urls.py index d4519fb81..e925838b5 100644 --- a/users/urls.py +++ b/users/urls.py @@ -23,6 +23,11 @@ views.UpdateSponsorshipAssetsView.as_view(), name="update_sponsorship_assets", ), + path( + "sponsorships//provided-assets/", + views.ProvidedSponsorshipAssetsView.as_view(), + name="view_provided_sponsorship_assets", + ), path( "sponsorships//", views.SponsorshipDetailView.as_view(), diff --git a/users/views.py b/users/views.py index 675796075..0f1a169a2 100644 --- a/users/views.py +++ b/users/views.py @@ -238,16 +238,23 @@ def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) sponsorship = context["sponsorship"] - assets = BenefitFeature.objects.required_assets().from_sponsorship(sponsorship) + required_assets = BenefitFeature.objects.required_assets().from_sponsorship(sponsorship) fulfilled, pending = [], [] - for asset in assets: + for asset in required_assets: if bool(asset.value): fulfilled.append(asset) else: pending.append(asset) - context["assets"] = pending + provided_assets = BenefitFeature.objects.provided_assets().from_sponsorship(sponsorship) + provided = [] + for asset in provided_assets: + if bool(asset.value): + provided.append(asset) + + context["required_assets"] = pending context["fulfilled_assets"] = fulfilled + context["provided_assets"] = provided context["sponsor"] = sponsorship.sponsor return context @@ -295,3 +302,23 @@ def get_success_url(self): def form_valid(self, form): form.update_assets() return redirect(self.get_success_url()) + + +@method_decorator(login_required(login_url=settings.LOGIN_URL), name="dispatch") +class ProvidedSponsorshipAssetsView(DetailView): + object_name = "sponsorship" + template_name = 'users/sponsorship_assets_view.html' + + def get_queryset(self): + return self.request.user.sponsorships.select_related("sponsor") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + provided_assets = BenefitFeature.objects.provided_assets().from_sponsorship(context["sponsorship"]) + provided = [] + for asset in provided_assets: + if bool(asset.value): + provided.append(asset) + context["provided_assets"] = provided + context["provided_asset_id"] = self.request.GET.get("provided_asset", None) + return context From 0801976016f795740c285dc38b6da7da86f7da01 Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Tue, 11 Jan 2022 16:02:42 -0500 Subject: [PATCH 3/9] upload files/shared provided file assets for configured benefits --- sponsors/admin.py | 13 ++- .../migrations/0070_auto_20220111_2055.py | 80 +++++++++++++++++++ sponsors/models/__init__.py | 3 +- sponsors/models/assets.py | 23 ++++++ sponsors/models/benefits.py | 59 +++++++++++++- 5 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 sponsors/migrations/0070_auto_20220111_2055.py diff --git a/sponsors/admin.py b/sponsors/admin.py index 046f9e42f..243d20d7f 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -6,6 +6,7 @@ from django.template import Context, Template from django.contrib import admin from django.contrib.humanize.templatetags.humanize import intcomma +from django.forms import ModelForm from django.urls import path, reverse, resolve from django.utils.functional import cached_property from django.utils.html import mark_safe @@ -46,7 +47,14 @@ class SponsorshipProgramAdmin(OrderedModelAdmin): ] +class MultiPartForceForm(ModelForm): + def is_multipart(self): + return True + + class BenefitFeatureConfigurationInline(StackedPolymorphicInline): + form = MultiPartForceForm + class LogoPlacementConfigurationInline(StackedPolymorphicInline.Child): model = LogoPlacementConfiguration @@ -70,6 +78,9 @@ class RequiredTextAssetConfigurationInline(StackedPolymorphicInline.Child): class ProvidedTextAssetConfigurationInline(StackedPolymorphicInline.Child): model = ProvidedTextAssetConfiguration + class ProvidedFileAssetConfigurationInline(StackedPolymorphicInline.Child): + model = ProvidedFileAssetConfiguration + model = BenefitFeatureConfiguration child_inlines = [ LogoPlacementConfigurationInline, @@ -78,9 +89,9 @@ class ProvidedTextAssetConfigurationInline(StackedPolymorphicInline.Child): RequiredImgAssetConfigurationInline, RequiredTextAssetConfigurationInline, ProvidedTextAssetConfigurationInline, + ProvidedFileAssetConfigurationInline, ] - @admin.register(SponsorshipBenefit) class SponsorshipBenefitAdmin(PolymorphicInlineSupportMixin, OrderedModelAdmin): change_form_template = "sponsors/admin/sponsorshipbenefit_change_form.html" diff --git a/sponsors/migrations/0070_auto_20220111_2055.py b/sponsors/migrations/0070_auto_20220111_2055.py new file mode 100644 index 000000000..94f8075cb --- /dev/null +++ b/sponsors/migrations/0070_auto_20220111_2055.py @@ -0,0 +1,80 @@ +# Generated by Django 2.2.24 on 2022-01-11 20:55 + +from django.db import migrations, models +import django.db.models.deletion +import sponsors.models.assets +import sponsors.models.benefits + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0069_auto_20220110_2148'), + ] + + operations = [ + migrations.CreateModel( + name='FileAsset', + fields=[ + ('genericasset_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='sponsors.GenericAsset')), + ('file', models.FileField(null=True, upload_to=sponsors.models.assets.generic_asset_path)), + ], + options={ + 'verbose_name': 'File Asset', + 'verbose_name_plural': 'File Assets', + }, + bases=('sponsors.genericasset',), + ), + migrations.CreateModel( + name='ProvidedFileAsset', + fields=[ + ('benefitfeature_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='sponsors.BenefitFeature')), + ('related_to', models.CharField(choices=[('sponsor', 'Sponsor'), ('sponsorship', 'Sponsorship')], help_text='To which instance (Sponsor or Sponsorship) should this asset relate to.', max_length=30, verbose_name='Related To')), + ('internal_name', models.CharField(db_index=True, help_text='Unique name used internally to control if the sponsor/sponsorship already has the asset', max_length=128, verbose_name='Internal Name')), + ('shared', models.BooleanField(default=False)), + ('label', models.CharField(help_text="What's the title used to display the file to the sponsor?", max_length=256)), + ('help_text', models.CharField(blank=True, default='', help_text='Any helper comment on how the file should be used', max_length=256)), + ('shared_file', models.FileField(blank=True, null=True, upload_to='')), + ], + options={ + 'verbose_name': 'Provided File', + 'verbose_name_plural': 'Provided Files', + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=(sponsors.models.benefits.ProvidedAssetMixin, 'sponsors.benefitfeature', models.Model), + ), + migrations.CreateModel( + name='ProvidedFileAssetConfiguration', + fields=[ + ('benefitfeatureconfiguration_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='sponsors.BenefitFeatureConfiguration')), + ('related_to', models.CharField(choices=[('sponsor', 'Sponsor'), ('sponsorship', 'Sponsorship')], help_text='To which instance (Sponsor or Sponsorship) should this asset relate to.', max_length=30, verbose_name='Related To')), + ('internal_name', models.CharField(db_index=True, help_text='Unique name used internally to control if the sponsor/sponsorship already has the asset', max_length=128, verbose_name='Internal Name')), + ('shared', models.BooleanField(default=False)), + ('label', models.CharField(help_text="What's the title used to display the file to the sponsor?", max_length=256)), + ('help_text', models.CharField(blank=True, default='', help_text='Any helper comment on how the file should be used', max_length=256)), + ('shared_file', models.FileField(blank=True, null=True, upload_to='')), + ], + options={ + 'verbose_name': 'Provided File Configuration', + 'verbose_name_plural': 'Provided File Configurations', + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=(sponsors.models.benefits.AssetConfigurationMixin, 'sponsors.benefitfeatureconfiguration', models.Model), + ), + migrations.AddField( + model_name='providedtextasset', + name='shared', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='providedtextassetconfiguration', + name='shared', + field=models.BooleanField(default=False), + ), + migrations.AddConstraint( + model_name='providedfileassetconfiguration', + constraint=models.UniqueConstraint(fields=('internal_name',), name='uniq_provided_file_asset_cfg'), + ), + ] diff --git a/sponsors/models/__init__.py b/sponsors/models/__init__.py index 0fc61de22..143bb83fa 100644 --- a/sponsors/models/__init__.py +++ b/sponsors/models/__init__.py @@ -10,6 +10,7 @@ from .benefits import BaseLogoPlacement, BaseTieredQuantity, BaseEmailTargetable, BenefitFeatureConfiguration, \ LogoPlacementConfiguration, TieredQuantityConfiguration, EmailTargetableConfiguration, BenefitFeature, \ LogoPlacement, EmailTargetable, TieredQuantity, RequiredImgAsset, RequiredImgAssetConfiguration, \ - RequiredTextAssetConfiguration, RequiredTextAsset, ProvidedTextAssetConfiguration, ProvidedTextAsset + RequiredTextAssetConfiguration, RequiredTextAsset, ProvidedTextAssetConfiguration, ProvidedTextAsset, \ + ProvidedFileAssetConfiguration, ProvidedFileAsset from .sponsorship import Sponsorship, SponsorshipProgram, SponsorshipBenefit, Sponsorship, SponsorshipPackage from .contract import LegalClause, Contract, signed_contract_random_path diff --git a/sponsors/models/assets.py b/sponsors/models/assets.py index 35ba198fa..3268d2594 100644 --- a/sponsors/models/assets.py +++ b/sponsors/models/assets.py @@ -90,3 +90,26 @@ def value(self): @value.setter def value(self, value): self.text = value + + +class FileAsset(GenericAsset): + file = models.FileField( + upload_to=generic_asset_path, + blank=False, + null=True, + ) + + def __str__(self): + return f"File asset: {self.internal_name}" + + class Meta: + verbose_name = "File Asset" + verbose_name_plural = "File Assets" + + @property + def value(self): + return self.file + + @value.setter + def value(self, value): + self.file = value diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index 66a8000c1..50c46fb1f 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -7,7 +7,7 @@ from django.urls import reverse from polymorphic.models import PolymorphicModel -from sponsors.models.assets import ImgAsset, TextAsset +from sponsors.models.assets import ImgAsset, TextAsset, FileAsset from sponsors.models.enums import PublisherChoices, LogoPlacementChoices, AssetsRelatedTo ######################################## @@ -93,6 +93,13 @@ class Meta: class BaseProvidedAsset(BaseAsset): + shared = models.BooleanField( + default = False, + ) + + def shared_value(self): + return None + class Meta: abstract = True @@ -176,6 +183,27 @@ class BaseProvidedTextAsset(BaseProvidedAsset): class Meta(BaseProvidedAsset.Meta): abstract = True +class BaseProvidedFileAsset(BaseProvidedAsset): + ASSET_CLASS = FileAsset + + label = models.CharField( + max_length=256, + help_text="What's the title used to display the file to the sponsor?" + ) + help_text = models.CharField( + max_length=256, + help_text="Any helper comment on how the file should be used", + default="", + blank=True + ) + shared_file = models.FileField(blank=True, null=True) + + def shared_value(self): + return self.shared_file + + class Meta(BaseProvidedAsset.Meta): + abstract = True + class AssetMixin: @@ -222,8 +250,12 @@ class ProvidedAssetMixin(AssetMixin): It's a mixin to get the information submitted by the staff and which is stored in the related asset class. """ - pass + @property + def value(self): + if hasattr(self, 'shared') and self.shared: + return self.shared_value() + return super().value ###################################################### # SponsorshipBenefit features configuration models @@ -392,6 +424,21 @@ def benefit_feature_class(self): return ProvidedTextAsset +class ProvidedFileAssetConfiguration(AssetConfigurationMixin, BaseProvidedFileAsset, + BenefitFeatureConfiguration): + class Meta(BaseProvidedFileAsset.Meta, BenefitFeatureConfiguration.Meta): + verbose_name = "Provided File Configuration" + verbose_name_plural = "Provided File Configurations" + constraints = [UniqueConstraint(fields=["internal_name"], name="uniq_provided_file_asset_cfg")] + + def __str__(self): + return f"Provided File configuration" + + @property + def benefit_feature_class(self): + return ProvidedFileAsset + + #################################### # SponsorBenefit features models class BenefitFeature(PolymorphicModel): @@ -489,3 +536,11 @@ class Meta(BaseProvidedTextAsset.Meta, BenefitFeature.Meta): def __str__(self): return f"Provided text" + +class ProvidedFileAsset(ProvidedAssetMixin, BaseProvidedFileAsset, BenefitFeature): + class Meta(BaseProvidedFileAsset.Meta, BenefitFeature.Meta): + verbose_name = "Provided File" + verbose_name_plural = "Provided Files" + + def __str__(self): + return f"Provided file" From dac31580e68a8a8fe2993b2020432bf6449aaa5d Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 13 Jan 2022 15:21:24 -0300 Subject: [PATCH 4/9] Make sure delete operations work as expected for Polymorphic models While testing the PR, I discovered this bug because I couldn't delete applications I've created. After doing some research, I figured out this is due to a known bug on django-polymorphic. More on this issue can be found in this issue: https://github.com/django-polymorphic/django-polymorphic/issues/229 --- sponsors/models/managers.py | 6 ++++++ sponsors/models/sponsors.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/sponsors/models/managers.py b/sponsors/models/managers.py index a6735644e..cc8657aea 100644 --- a/sponsors/models/managers.py +++ b/sponsors/models/managers.py @@ -100,6 +100,12 @@ def list_advertisables(self): class BenefitFeatureQuerySet(PolymorphicQuerySet): + def delete(self): + if not self.polymorphic_disabled: + return self.non_polymorphic().delete() + else: + return super().delete() + def from_sponsorship(self, sponsorship): return self.filter(sponsor_benefit__sponsorship=sponsorship).select_related("sponsor_benefit__sponsorship") diff --git a/sponsors/models/sponsors.py b/sponsors/models/sponsors.py index 8cd7f26e9..48e07d196 100644 --- a/sponsors/models/sponsors.py +++ b/sponsors/models/sponsors.py @@ -252,5 +252,9 @@ def name_for_display(self): name = feature.display_modifier(name) return name + def delete(self): + self.features.all().delete() + super().delete() + class Meta(OrderedModel.Meta): pass From 148b66c063fb69bfe29151b0e7c008dbe5b4526e Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 19 Jan 2022 10:16:51 -0300 Subject: [PATCH 5/9] Refactor how to reconfigure sponsor benefit to avoid delete operations --- sponsors/models/sponsors.py | 13 +++++++++++++ sponsors/tests/test_models.py | 17 +++++++++++++++++ sponsors/tests/test_views_admin.py | 14 ++++---------- sponsors/views_admin.py | 12 +++--------- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/sponsors/models/sponsors.py b/sponsors/models/sponsors.py index 48e07d196..5f7e683d1 100644 --- a/sponsors/models/sponsors.py +++ b/sponsors/models/sponsors.py @@ -252,6 +252,19 @@ def name_for_display(self): name = feature.display_modifier(name) return name + def reset_attributes(self, benefit): + """ + This method resets all the sponsor benefit information + fetching new data from the sponsorship benefit. + """ + self.program_name = benefit.program.name + self.name = benefit.name + self.description = benefit.description + self.program = benefit.program + self.benefit_internal_value = benefit.internal_value + self.a_la_carte = benefit.a_la_carte + self.added_by_user = self.added_by_user or self.a_la_carte + def delete(self): self.features.all().delete() super().delete() diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index a73af52ec..53f5441cb 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -636,6 +636,23 @@ def test_sponsor_benefit_from_a_la_carte_one(self): self.assertTrue(sponsor_benefit.added_by_user) self.assertTrue(sponsor_benefit.a_la_carte) + def test_reset_attributes_updates_all_basic_information(self): + benefit = baker.make( + SponsorBenefit, sponsorship_benefit=self.sponsorship_benefit + ) + # both have different random values + self.assertNotEqual(benefit.name, self.sponsorship_benefit.name) + + benefit.reset_attributes(self.sponsorship_benefit) + benefit.refresh_from_db() + + self.assertEqual(benefit.name, self.sponsorship_benefit.name) + self.assertEqual(benefit.description, self.sponsorship_benefit.description) + self.assertEqual(benefit.program_name, self.sponsorship_benefit.program.name) + self.assertEqual(benefit.program, self.sponsorship_benefit.program) + self.assertEqual(benefit.benefit_internal_value, self.sponsorship_benefit.internal_value) + self.assertEqual(benefit.a_la_carte, self.sponsorship_benefit.a_la_carte) + ########### # Email notification tests class SponsorEmailNotificationTemplateTests(TestCase): diff --git a/sponsors/tests/test_views_admin.py b/sponsors/tests/test_views_admin.py index 0de2bdc75..1e4ba15d6 100644 --- a/sponsors/tests/test_views_admin.py +++ b/sponsors/tests/test_views_admin.py @@ -833,16 +833,10 @@ def test_update_selected_sponsorships_only(self): response = self.client.post(self.url, data=self.data) - # delete existing sponsor benefit - self.assertFalse(SponsorBenefit.objects.filter(id=self.sponsor_benefit.id).exists()) - # make sure a new one was created - new_sponsor_benefit = SponsorBenefit.objects.get( - sponsorship=self.sponsor_benefit.sponsorship, - sponsorship_benefit=self.benefit, - ) - self.assertEqual(new_sponsor_benefit.name, "New name") - self.assertEqual(new_sponsor_benefit.description, "New description") - self.assertTrue(new_sponsor_benefit.added_by_user) + self.sponsor_benefit.refresh_from_db() + self.assertEqual(self.sponsor_benefit.name, "New name") + self.assertEqual(self.sponsor_benefit.description, "New description") + self.assertTrue(self.sponsor_benefit.added_by_user) # make sure sponsor benefit from unselected sponsorships wasn't deleted other_sponsor_benefit.refresh_from_db() self.assertEqual(other_sponsor_benefit.name, prev_name) diff --git a/sponsors/views_admin.py b/sponsors/views_admin.py index b1a216abd..c2d6a11ca 100644 --- a/sponsors/views_admin.py +++ b/sponsors/views_admin.py @@ -240,7 +240,8 @@ def update_related_sponsorships(ModelAdmin, request, pk): Given a SponsorshipBeneefit, update all releated SponsorBenefit from the Sponsorship listed in the post payload """ - benefit = get_object_or_404(ModelAdmin.get_queryset(request), pk=pk) + qs = ModelAdmin.get_queryset(request).select_related("program") + benefit = get_object_or_404(qs, pk=pk) initial = {"sponsorships": [sp.pk for sp in benefit.related_sponsorships]} form = SponsorshipsListForm.with_benefit(benefit, initial=initial) @@ -252,14 +253,7 @@ def update_related_sponsorships(ModelAdmin, request, pk): related_benefits = benefit.sponsorbenefit_set.all() for sp in sponsorships: sponsor_benefit = related_benefits.get(sponsorship=sp) - sponsor_benefit.delete() - - # recreate sponsor benefit considering updated benefit/feature configs - SponsorBenefit.new_copy( - benefit, - sponsorship=sp, - added_by_user=sponsor_benefit.added_by_user - ) + sponsor_benefit.reset_attributes(benefit) ModelAdmin.message_user( request, f"{len(sponsorships)} related sponsorships updated!", messages.SUCCESS From 95222e296fa5ca366179f0e3774f43fd77624ec7 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 19 Jan 2022 11:19:06 -0300 Subject: [PATCH 6/9] Make sure the assets are also being updated --- sponsors/models/benefits.py | 1 + sponsors/models/sponsors.py | 9 +++++ sponsors/tests/test_models.py | 64 +++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index 50c46fb1f..9ceb3048e 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -537,6 +537,7 @@ class Meta(BaseProvidedTextAsset.Meta, BenefitFeature.Meta): def __str__(self): return f"Provided text" + class ProvidedFileAsset(ProvidedAssetMixin, BaseProvidedFileAsset, BenefitFeature): class Meta(BaseProvidedFileAsset.Meta, BenefitFeature.Meta): verbose_name = "Provided File" diff --git a/sponsors/models/sponsors.py b/sponsors/models/sponsors.py index 5f7e683d1..7475281a7 100644 --- a/sponsors/models/sponsors.py +++ b/sponsors/models/sponsors.py @@ -4,6 +4,7 @@ from allauth.account.models import EmailAddress from django.conf import settings from django.db import models +from django.core.exceptions import ObjectDoesNotExist from django_countries.fields import CountryField from ordered_model.models import OrderedModel from django.contrib.contenttypes.fields import GenericRelation @@ -265,6 +266,14 @@ def reset_attributes(self, benefit): self.a_la_carte = benefit.a_la_carte self.added_by_user = self.added_by_user or self.a_la_carte + # generate benefit features from benefit features configurations + features = self.features.all().delete() + for feature_config in benefit.features_config.all(): + feature_config.create_benefit_feature(self) + + self.save() + + def delete(self): self.features.all().delete() super().delete() diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 53f5441cb..0822a9efa 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -653,6 +653,70 @@ def test_reset_attributes_updates_all_basic_information(self): self.assertEqual(benefit.benefit_internal_value, self.sponsorship_benefit.internal_value) self.assertEqual(benefit.a_la_carte, self.sponsorship_benefit.a_la_carte) + def test_reset_attributes_add_new_features(self): + RequiredTextAssetConfiguration.objects.create( + benefit=self.sponsorship_benefit, + related_to="sponsorship", + internal_name="foo", + label="Text", + ) + benefit = baker.make( + SponsorBenefit, sponsorship_benefit=self.sponsorship_benefit + ) + # no previous feature + self.assertFalse(benefit.features.count()) + + benefit.reset_attributes(self.sponsorship_benefit) + benefit.refresh_from_db() + + self.assertEqual(1, benefit.features.count()) + + def test_reset_attributes_delete_removed_features(self): + cfg = RequiredTextAssetConfiguration.objects.create( + benefit=self.sponsorship_benefit, + related_to="sponsorship", + internal_name="foo", + label="Text", + ) + benefit = SponsorBenefit.new_copy( + self.sponsorship_benefit, sponsorship=self.sponsorship + ) + self.assertEqual(1, benefit.features.count()) + cfg.delete() + + benefit.reset_attributes(self.sponsorship_benefit) + benefit.refresh_from_db() + + # no previous feature + self.assertFalse(benefit.features.count()) + + def test_reset_attributes_recreate_features_but_keeping_previous_values(self): + cfg = RequiredTextAssetConfiguration.objects.create( + benefit=self.sponsorship_benefit, + related_to="sponsorship", + internal_name="foo", + label="Text", + ) + benefit = SponsorBenefit.new_copy( + self.sponsorship_benefit, sponsorship=self.sponsorship + ) + + feature = RequiredTextAsset.objects.get() + feature.value = "foo" + feature.save() + cfg.label = "New text" + cfg.save() + + benefit.reset_attributes(self.sponsorship_benefit) + benefit.refresh_from_db() + + # no previous feature + self.assertEqual(1, benefit.features.count()) + asset = benefit.features.required_assets().get() + self.assertEqual(asset.label, "New text") + self.assertEqual(asset.value, "foo") + + ########### # Email notification tests class SponsorEmailNotificationTemplateTests(TestCase): From 4d4017b068117836af8a80ba5578afee8dcb40ef Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 19 Jan 2022 11:22:56 -0300 Subject: [PATCH 7/9] Add docstring to make it more clear that the input assets are decoupled from their configuration --- sponsors/models/benefits.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index 9ceb3048e..c99b1e317 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -208,6 +208,12 @@ class Meta(BaseProvidedAsset.Meta): class AssetMixin: def __related_asset(self): + """ + This method exists to avoid FK relationships between the GenericAsset + and reuired asset objects. This is to decouple the assets set up from the + real assets value in a way that, if the first gets deleted, the second can + still be re used. + """ object = self.sponsor_benefit.sponsorship if self.related_to == AssetsRelatedTo.SPONSOR.value: object = self.sponsor_benefit.sponsorship.sponsor From 0be724970840c7ed3d1aa3a274cd1dac49ae62c3 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 19 Jan 2022 11:41:53 -0300 Subject: [PATCH 8/9] Fix bad context variable name --- templates/users/sponsorship_detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/users/sponsorship_detail.html b/templates/users/sponsorship_detail.html index 36d3e0961..6c9bceb13 100644 --- a/templates/users/sponsorship_detail.html +++ b/templates/users/sponsorship_detail.html @@ -90,7 +90,7 @@

    Application Data

- {% if assets or fulfilled_assets %} + {% if required_assets or fulfilled_assets %}

Required Assets

You've selected benefits which requires extra assets (logos, slides etc).

From 9dccf57def4dbc83434937d189ddc4c35348a33f Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 19 Jan 2022 12:25:19 -0300 Subject: [PATCH 9/9] Fields that configure relationship between assets and configuration should be read only for editing --- sponsors/admin.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index 243d20d7f..d15cf2882 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -68,17 +68,25 @@ class EmailTargetableConfigurationInline(StackedPolymorphicInline.Child): def display(self, obj): return "Enabled" - class RequiredImgAssetConfigurationInline(StackedPolymorphicInline.Child): + class BaseAssetInline(StackedPolymorphicInline.Child): + + def get_readonly_fields(self, request, obj=None): + fields = list(super().get_readonly_fields(request, obj)) + if obj: + fields.extend(["internal_name", "related_to"]) + return fields + + class RequiredImgAssetConfigurationInline(BaseAssetInline): model = RequiredImgAssetConfiguration form = RequiredImgAssetConfigurationForm - class RequiredTextAssetConfigurationInline(StackedPolymorphicInline.Child): + class RequiredTextAssetConfigurationInline(BaseAssetInline): model = RequiredTextAssetConfiguration - class ProvidedTextAssetConfigurationInline(StackedPolymorphicInline.Child): + class ProvidedTextAssetConfigurationInline(BaseAssetInline): model = ProvidedTextAssetConfiguration - class ProvidedFileAssetConfigurationInline(StackedPolymorphicInline.Child): + class ProvidedFileAssetConfigurationInline(BaseAssetInline): model = ProvidedFileAssetConfiguration model = BenefitFeatureConfiguration