From e320f6f696972c69b55a46d13e82445a9141edbd Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 26 Oct 2021 11:10:05 -0300 Subject: [PATCH 01/22] Add missing migrations after changes on the field names and texts --- .../migrations/0051_auto_20211022_1403.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 sponsors/migrations/0051_auto_20211022_1403.py diff --git a/sponsors/migrations/0051_auto_20211022_1403.py b/sponsors/migrations/0051_auto_20211022_1403.py new file mode 100644 index 000000000..0ad5e0c8f --- /dev/null +++ b/sponsors/migrations/0051_auto_20211022_1403.py @@ -0,0 +1,54 @@ +# Generated by Django 2.2.24 on 2021-10-22 14:03 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0050_emailtargetable_emailtargetableconfiguration'), + ] + + operations = [ + migrations.AlterField( + model_name='sponsor', + name='description', + field=models.TextField(help_text='Brief description of the sponsor for public display.', verbose_name='Description'), + ), + migrations.AlterField( + model_name='sponsor', + name='landing_page_url', + field=models.URLField(blank=True, help_text='Landing page URL. This may be provided by the sponsor, however the linked page may not contain any sales or marketing information.', null=True, verbose_name='Landing page URL'), + ), + migrations.AlterField( + model_name='sponsor', + name='name', + field=models.CharField(help_text='Name of the sponsor, for public display.', max_length=100, verbose_name='Name'), + ), + migrations.AlterField( + model_name='sponsor', + name='primary_phone', + field=models.CharField(max_length=32, verbose_name='Primary Phone'), + ), + migrations.AlterField( + model_name='sponsor', + name='print_logo', + field=models.FileField(blank=True, help_text='For printed materials, signage, and projection. SVG or EPS', null=True, upload_to='sponsor_print_logos', verbose_name='Print logo'), + ), + migrations.AlterField( + model_name='sponsor', + name='twitter_handle', + field=models.CharField(blank=True, max_length=32, null=True, verbose_name='Twitter handle'), + ), + migrations.AlterField( + model_name='sponsor', + name='web_logo', + field=models.ImageField(help_text='For display on our sponsor webpage. High resolution PNG or JPG, smallest dimension no less than 256px', upload_to='sponsor_web_logos', verbose_name='Web logo'), + ), + migrations.AlterField( + model_name='sponsorcontact', + name='primary', + field=models.BooleanField(default=False, help_text='The primary contact for a sponsorship will be responsible for managing deliverables we need to fulfill benefits. Primary contacts will receive all email notifications regarding sponsorship. '), + ), + ] From 668609d4456a0a0e28ab3ee831687e7ca14cd8a0 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 26 Oct 2021 11:11:57 -0300 Subject: [PATCH 02/22] Add new benefit feature to flag benefits with required image assets --- ...dimgasset_requiredimgassetconfiguration.py | 52 +++++++++++++++++++ sponsors/models/__init__.py | 2 +- sponsors/models/benefits.py | 52 +++++++++++++++++-- sponsors/models/enums.py | 6 +++ 4 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 sponsors/migrations/0052_requiredimgasset_requiredimgassetconfiguration.py diff --git a/sponsors/migrations/0052_requiredimgasset_requiredimgassetconfiguration.py b/sponsors/migrations/0052_requiredimgasset_requiredimgassetconfiguration.py new file mode 100644 index 000000000..c2946655f --- /dev/null +++ b/sponsors/migrations/0052_requiredimgasset_requiredimgassetconfiguration.py @@ -0,0 +1,52 @@ +# Generated by Django 2.2.24 on 2021-10-22 14:04 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0051_auto_20211022_1403'), + ] + + operations = [ + migrations.CreateModel( + name='RequiredImgAsset', + 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, unique=True, verbose_name='Internal Name')), + ('min_width', models.PositiveIntegerField()), + ('max_width', models.PositiveIntegerField()), + ('min_height', models.PositiveIntegerField()), + ('max_height', models.PositiveIntegerField()), + ], + options={ + 'verbose_name': 'Require Image Benefit', + 'verbose_name_plural': 'Require Image Benefits', + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('sponsors.benefitfeature', models.Model), + ), + migrations.CreateModel( + name='RequiredImgAssetConfiguration', + 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, unique=True, verbose_name='Internal Name')), + ('min_width', models.PositiveIntegerField()), + ('max_width', models.PositiveIntegerField()), + ('min_height', models.PositiveIntegerField()), + ('max_height', models.PositiveIntegerField()), + ], + options={ + 'verbose_name': 'Require Image Configuration', + 'verbose_name_plural': 'Require Image Configurations', + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('sponsors.benefitfeatureconfiguration', models.Model), + ), + ] diff --git a/sponsors/models/__init__.py b/sponsors/models/__init__.py index bdbce66ab..a05f5af20 100644 --- a/sponsors/models/__init__.py +++ b/sponsors/models/__init__.py @@ -8,6 +8,6 @@ from .sponsors import Sponsor, SponsorContact, SponsorBenefit from .benefits import BaseLogoPlacement, BaseTieredQuantity, BaseEmailTargetable, BenefitFeatureConfiguration, \ LogoPlacementConfiguration, TieredQuantityConfiguration, EmailTargetableConfiguration, BenefitFeature, \ - LogoPlacement, EmailTargetable, TieredQuantity + LogoPlacement, EmailTargetable, TieredQuantity, RequiredImgAsset, RequiredImgAssetConfiguration 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 27f40ed4d..a454447d4 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -3,14 +3,13 @@ """ from django.db import models - -######################################## -# Benefit features abstract classes from polymorphic.models import PolymorphicModel -from sponsors.models.enums import PublisherChoices, LogoPlacementChoices +from sponsors.models.enums import PublisherChoices, LogoPlacementChoices, AssetsRelatedTo +######################################## +# Benefit features abstract classes class BaseLogoPlacement(models.Model): publisher = models.CharField( max_length=30, @@ -37,6 +36,29 @@ class Meta: abstract = True +class BaseRequiredImgAsset(models.Model): + related_to = models.CharField( + max_length=30, + choices=[(c.value, c.name.replace("_", " ").title()) for c in AssetsRelatedTo], + verbose_name="Related To", + help_text="To which instance (Sponsor or Sponsorship) should this asset relate to." + ) + internal_name = models.CharField( + max_length=128, + verbose_name="Internal Name", + help_text="Unique name used internally to control if the sponsor/sponsorship already has the asset", + unique=True, + db_index=True, + ) + min_width = models.PositiveIntegerField() + max_width = models.PositiveIntegerField() + min_height = models.PositiveIntegerField() + max_height = models.PositiveIntegerField() + + class Meta: + abstract = True + + class BaseEmailTargetable(models.Model): class Meta: abstract = True @@ -156,6 +178,19 @@ def __str__(self): return f"Email targeatable configuration" +class RequiredImgAssetConfiguration(BaseRequiredImgAsset, BenefitFeatureConfiguration): + class Meta(BaseRequiredImgAsset.Meta, BenefitFeatureConfiguration.Meta): + verbose_name = "Require Image Configuration" + verbose_name_plural = "Require Image Configurations" + + def __str__(self): + return f"Require image configuration" + + @property + def benefit_feature_class(self): + return RequiredImgAsset + + #################################### # SponsorBenefit features models class BenefitFeature(PolymorphicModel): @@ -213,3 +248,12 @@ class Meta(BaseTieredQuantity.Meta, BenefitFeature.Meta): def __str__(self): return f"Email targeatable" + + +class RequiredImgAsset(BaseRequiredImgAsset, BenefitFeature): + class Meta(BaseRequiredImgAsset.Meta, BenefitFeature.Meta): + verbose_name = "Require Image Benefit" + verbose_name_plural = "Require Image Benefits" + + def __str__(self): + return f"Require image" diff --git a/sponsors/models/enums.py b/sponsors/models/enums.py index 1387bbdac..69a8c6ba2 100644 --- a/sponsors/models/enums.py +++ b/sponsors/models/enums.py @@ -11,8 +11,14 @@ class LogoPlacementChoices(Enum): DOWNLOAD_PAGE = "download" DEV_GUIDE = "devguide" + class PublisherChoices(Enum): FOUNDATION = "psf" PYCON = "pycon" PYPI = "pypi" CORE_DEV = "core" + + +class AssetsRelatedTo(Enum): + SPONSOR = "sponsor" + SPONSORSHIP = "sponsorship" From 99754317e0773cd8efd531e616adc6a61699988c Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 26 Oct 2021 11:12:56 -0300 Subject: [PATCH 03/22] Add new configuration to admin --- sponsors/admin.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index bd1100eee..b4c9cabbe 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -10,23 +10,7 @@ from django.utils.html import mark_safe from mailing.admin import BaseEmailTemplateAdmin -from .models import ( - SponsorshipPackage, - SponsorshipProgram, - SponsorshipBenefit, - Sponsor, - Sponsorship, - SponsorContact, - SponsorBenefit, - LegalClause, - Contract, - BenefitFeature, - BenefitFeatureConfiguration, - LogoPlacementConfiguration, - TieredQuantityConfiguration, - EmailTargetableConfiguration, - SponsorEmailNotificationTemplate, -) +from sponsors.models import * from sponsors import views_admin from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm from cms.admin import ContentManageableModelAdmin @@ -55,11 +39,15 @@ class EmailTargetableConfigurationInline(StackedPolymorphicInline.Child): def display(self, obj): return "Enabled" + class RequiredImgAssetConfigurationInline(StackedPolymorphicInline.Child): + model = RequiredImgAssetConfiguration + model = BenefitFeatureConfiguration child_inlines = [ LogoPlacementConfigurationInline, TieredQuantityConfigurationInline, EmailTargetableConfigurationInline, + RequiredImgAssetConfigurationInline, ] From 0be88099e4159a4607815353d658a618b8f07455 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 26 Oct 2021 11:13:06 -0300 Subject: [PATCH 04/22] Validate min/max ranges --- sponsors/admin.py | 3 ++- sponsors/forms.py | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index b4c9cabbe..c092f3a6c 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -12,7 +12,7 @@ from mailing.admin import BaseEmailTemplateAdmin from sponsors.models import * from sponsors import views_admin -from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm +from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm, RequiredImgAssetConfigurationForm from cms.admin import ContentManageableModelAdmin @@ -41,6 +41,7 @@ def display(self, obj): class RequiredImgAssetConfigurationInline(StackedPolymorphicInline.Child): model = RequiredImgAssetConfiguration + form = RequiredImgAssetConfigurationForm model = BenefitFeatureConfiguration child_inlines = [ diff --git a/sponsors/forms.py b/sponsors/forms.py index dbea36b6a..57517fdaa 100644 --- a/sponsors/forms.py +++ b/sponsors/forms.py @@ -17,7 +17,8 @@ SponsorContact, Sponsorship, SponsorBenefit, - SponsorEmailNotificationTemplate + SponsorEmailNotificationTemplate, + RequiredImgAssetConfiguration, ) @@ -526,3 +527,22 @@ def clean(self): def save(self, *args, **kwargs): super().save(*args, **kwargs) self.contacts_formset.save() + + +class RequiredImgAssetConfigurationForm(forms.ModelForm): + + def clean(self): + data = super().clean() + + min_width, max_width = data.get("min_width"), data.get("max_width") + if min_width and max_width and max_width < min_width: + raise forms.ValidationError("Max width must be greater than min width") + min_height, max_height = data.get("min_height"), data.get("max_height") + if min_height and max_height and max_height < min_height: + raise forms.ValidationError("Max height must be greater than min height") + + return data + + class Meta: + model = RequiredImgAssetConfiguration + fields = "__all__" From 129d2ed95b2b654453a583c856a2b6c065b90368 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 26 Oct 2021 11:23:57 -0300 Subject: [PATCH 05/22] Model class to hold generic img assets --- .../migrations/0053_genericasset_imgasset.py | 43 ++++++++++++++ sponsors/models/__init__.py | 1 + sponsors/models/assets.py | 58 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 sponsors/migrations/0053_genericasset_imgasset.py create mode 100644 sponsors/models/assets.py diff --git a/sponsors/migrations/0053_genericasset_imgasset.py b/sponsors/migrations/0053_genericasset_imgasset.py new file mode 100644 index 000000000..616016c0f --- /dev/null +++ b/sponsors/migrations/0053_genericasset_imgasset.py @@ -0,0 +1,43 @@ +# Generated by Django 2.2.24 on 2021-10-26 14:23 + +from django.db import migrations, models +import django.db.models.deletion +import sponsors.models.assets + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('sponsors', '0052_requiredimgasset_requiredimgassetconfiguration'), + ] + + operations = [ + migrations.CreateModel( + name='GenericAsset', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('internal_name', models.CharField(db_index=True, max_length=128, verbose_name='Internal Name')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_sponsors.genericasset_set+', to='contenttypes.ContentType')), + ], + options={ + 'verbose_name': 'Asset', + 'verbose_name_plural': 'Assets', + 'unique_together': {('content_type', 'object_id', 'internal_name')}, + }, + ), + migrations.CreateModel( + name='ImgAsset', + 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')), + ('image', models.ImageField(null=True, upload_to=sponsors.models.assets.generic_asset_path)), + ], + options={ + 'verbose_name': 'Image Asset', + 'verbose_name_plural': 'Image Assets', + }, + bases=('sponsors.genericasset',), + ), + ] diff --git a/sponsors/models/__init__.py b/sponsors/models/__init__.py index a05f5af20..75af22ba5 100644 --- a/sponsors/models/__init__.py +++ b/sponsors/models/__init__.py @@ -4,6 +4,7 @@ structured as a python package. """ +from .assets import GenericAsset, ImgAsset from .notifications import SponsorEmailNotificationTemplate from .sponsors import Sponsor, SponsorContact, SponsorBenefit from .benefits import BaseLogoPlacement, BaseTieredQuantity, BaseEmailTargetable, BenefitFeatureConfiguration, \ diff --git a/sponsors/models/assets.py b/sponsors/models/assets.py new file mode 100644 index 000000000..5ca1f10b1 --- /dev/null +++ b/sponsors/models/assets.py @@ -0,0 +1,58 @@ +""" +This module holds models to store generic assets +from Sponsors or Sponsorships +""" +from pathlib import Path + +from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from polymorphic.models import PolymorphicModel + + +def generic_asset_path(instance, filename): + """ + Uses internal name + content type + obj id to avoid name collisions + """ + directory = "sponsors-assets" + ext = "".join(Path(filename).suffixes) + name = f"{instance.internal_name} - {instance.content_type} - {instance.object_id}{ext}" + return f"{directory}{name}{ext}" + + +class GenericAsset(PolymorphicModel): + """ + Base class used to add required assets to Sponsor or Sponsorship objects + """ + # The next 3 fields are required by Django to enable and set up generic relations + # pointing the asset to a Sponsor or Sponsorship object + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + + # must match with internal_name from benefits configuration which describe assets + internal_name = models.CharField( + max_length=128, + verbose_name="Internal Name", + db_index=True, + ) + + class Meta: + verbose_name = "Asset" + verbose_name_plural = "Assets" + unique_together = ["content_type", "object_id", "internal_name"] + + +class ImgAsset(GenericAsset): + image = models.ImageField( + upload_to=generic_asset_path, + blank=False, + null=True, + ) + + def __str__(self): + return f"Image asset: {self.internal_name}" + + class Meta: + verbose_name = "Image Asset" + verbose_name_plural = "Image Assets" From d7355d1cddb5b820ac6c8fd85f5a7f126c35b07e Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 26 Oct 2021 11:48:46 -0300 Subject: [PATCH 06/22] Use UUID to format file paths --- .../migrations/0054_auto_20211026_1432.py | 23 +++++++++++++++++++ sponsors/models/assets.py | 6 +++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 sponsors/migrations/0054_auto_20211026_1432.py diff --git a/sponsors/migrations/0054_auto_20211026_1432.py b/sponsors/migrations/0054_auto_20211026_1432.py new file mode 100644 index 000000000..016aa90e1 --- /dev/null +++ b/sponsors/migrations/0054_auto_20211026_1432.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.24 on 2021-10-26 14:32 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0053_genericasset_imgasset'), + ] + + operations = [ + migrations.RemoveField( + model_name='genericasset', + name='id', + ), + migrations.AddField( + model_name='genericasset', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + ] diff --git a/sponsors/models/assets.py b/sponsors/models/assets.py index 5ca1f10b1..d6b50288e 100644 --- a/sponsors/models/assets.py +++ b/sponsors/models/assets.py @@ -2,6 +2,7 @@ This module holds models to store generic assets from Sponsors or Sponsorships """ +import uuid from pathlib import Path from django.db import models @@ -14,9 +15,9 @@ def generic_asset_path(instance, filename): """ Uses internal name + content type + obj id to avoid name collisions """ - directory = "sponsors-assets" + directory = "sponsors-app-assets" ext = "".join(Path(filename).suffixes) - name = f"{instance.internal_name} - {instance.content_type} - {instance.object_id}{ext}" + name = f"{instance.uuid}{ext}" return f"{directory}{name}{ext}" @@ -24,6 +25,7 @@ class GenericAsset(PolymorphicModel): """ Base class used to add required assets to Sponsor or Sponsorship objects """ + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # The next 3 fields are required by Django to enable and set up generic relations # pointing the asset to a Sponsor or Sponsorship object content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) From e21da03d8346a1931634656950537fb6a3aca54e Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 26 Oct 2021 11:50:57 -0300 Subject: [PATCH 07/22] Add new class to require text inputs --- sponsors/admin.py | 4 ++ .../migrations/0055_auto_20211026_1512.py | 52 +++++++++++++++++++ sponsors/models/__init__.py | 3 +- sponsors/models/benefits.py | 52 +++++++++++++++++-- 4 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 sponsors/migrations/0055_auto_20211026_1512.py diff --git a/sponsors/admin.py b/sponsors/admin.py index c092f3a6c..a4c89ccd0 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -43,12 +43,16 @@ class RequiredImgAssetConfigurationInline(StackedPolymorphicInline.Child): model = RequiredImgAssetConfiguration form = RequiredImgAssetConfigurationForm + class RequiredTextAssetConfigurationInline(StackedPolymorphicInline.Child): + model = RequiredTextAssetConfiguration + model = BenefitFeatureConfiguration child_inlines = [ LogoPlacementConfigurationInline, TieredQuantityConfigurationInline, EmailTargetableConfigurationInline, RequiredImgAssetConfigurationInline, + RequiredTextAssetConfigurationInline, ] diff --git a/sponsors/migrations/0055_auto_20211026_1512.py b/sponsors/migrations/0055_auto_20211026_1512.py new file mode 100644 index 000000000..5bcf047e7 --- /dev/null +++ b/sponsors/migrations/0055_auto_20211026_1512.py @@ -0,0 +1,52 @@ +# Generated by Django 2.2.24 on 2021-10-26 15:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0054_auto_20211026_1432'), + ] + + operations = [ + migrations.CreateModel( + name='RequiredTextAsset', + 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, unique=True, 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': 'Require Text', + 'verbose_name_plural': 'Require Texts', + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('sponsors.benefitfeature', models.Model), + ), + migrations.CreateModel( + name='RequiredTextAssetConfiguration', + 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, unique=True, 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': 'Require Text Configuration', + 'verbose_name_plural': 'Require Text Configurations', + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('sponsors.benefitfeatureconfiguration', models.Model), + ), + migrations.AlterModelOptions( + name='requiredimgasset', + options={'base_manager_name': 'objects', 'verbose_name': 'Require Image', 'verbose_name_plural': 'Require Images'}, + ), + ] diff --git a/sponsors/models/__init__.py b/sponsors/models/__init__.py index 75af22ba5..f5c0cb6e6 100644 --- a/sponsors/models/__init__.py +++ b/sponsors/models/__init__.py @@ -9,6 +9,7 @@ from .sponsors import Sponsor, SponsorContact, SponsorBenefit from .benefits import BaseLogoPlacement, BaseTieredQuantity, BaseEmailTargetable, BenefitFeatureConfiguration, \ LogoPlacementConfiguration, TieredQuantityConfiguration, EmailTargetableConfiguration, BenefitFeature, \ - LogoPlacement, EmailTargetable, TieredQuantity, RequiredImgAsset, RequiredImgAssetConfiguration + LogoPlacement, EmailTargetable, TieredQuantity, RequiredImgAsset, RequiredImgAssetConfiguration, \ + RequiredTextAssetConfiguration, RequiredTextAsset 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 a454447d4..c59d24885 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -36,7 +36,12 @@ class Meta: abstract = True -class BaseRequiredImgAsset(models.Model): +class BaseEmailTargetable(models.Model): + class Meta: + abstract = True + + +class BaseRequiredAsset(models.Model): related_to = models.CharField( max_length=30, choices=[(c.value, c.name.replace("_", " ").title()) for c in AssetsRelatedTo], @@ -50,6 +55,12 @@ class BaseRequiredImgAsset(models.Model): unique=True, db_index=True, ) + + class Meta: + abstract = True + + +class BaseRequiredImgAsset(BaseRequiredAsset): min_width = models.PositiveIntegerField() max_width = models.PositiveIntegerField() min_height = models.PositiveIntegerField() @@ -59,7 +70,18 @@ class Meta: abstract = True -class BaseEmailTargetable(models.Model): +class BaseRequiredTextAsset(BaseRequiredAsset): + 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: abstract = True @@ -191,6 +213,19 @@ def benefit_feature_class(self): return RequiredImgAsset +class RequiredTextAssetConfiguration(BaseRequiredTextAsset, BenefitFeatureConfiguration): + class Meta(BaseRequiredTextAsset.Meta, BenefitFeatureConfiguration.Meta): + verbose_name = "Require Text Configuration" + verbose_name_plural = "Require Text Configurations" + + def __str__(self): + return f"Require text configuration" + + @property + def benefit_feature_class(self): + return RequiredTextAsset + + #################################### # SponsorBenefit features models class BenefitFeature(PolymorphicModel): @@ -252,8 +287,17 @@ def __str__(self): class RequiredImgAsset(BaseRequiredImgAsset, BenefitFeature): class Meta(BaseRequiredImgAsset.Meta, BenefitFeature.Meta): - verbose_name = "Require Image Benefit" - verbose_name_plural = "Require Image Benefits" + verbose_name = "Require Image" + verbose_name_plural = "Require Images" def __str__(self): return f"Require image" + + +class RequiredTextAsset(BaseRequiredTextAsset, BenefitFeature): + class Meta(BaseRequiredTextAsset.Meta, BenefitFeature.Meta): + verbose_name = "Require Text" + verbose_name_plural = "Require Texts" + + def __str__(self): + return f"Require text" From 9366efdcf04635db58536b601f14f2e58c9effcb Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 26 Oct 2021 12:14:52 -0300 Subject: [PATCH 08/22] Add asset to store text input from user --- sponsors/migrations/0056_textasset.py | 26 ++++++++++++++++++++++++++ sponsors/models/__init__.py | 2 +- sponsors/models/assets.py | 12 ++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 sponsors/migrations/0056_textasset.py diff --git a/sponsors/migrations/0056_textasset.py b/sponsors/migrations/0056_textasset.py new file mode 100644 index 000000000..1fd7c68ba --- /dev/null +++ b/sponsors/migrations/0056_textasset.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.24 on 2021-10-26 15:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0055_auto_20211026_1512'), + ] + + operations = [ + migrations.CreateModel( + name='TextAsset', + 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')), + ('text', models.TextField(default='')), + ], + options={ + 'verbose_name': 'Image Asset', + 'verbose_name_plural': 'Image Assets', + }, + bases=('sponsors.genericasset',), + ), + ] diff --git a/sponsors/models/__init__.py b/sponsors/models/__init__.py index f5c0cb6e6..d7d2759be 100644 --- a/sponsors/models/__init__.py +++ b/sponsors/models/__init__.py @@ -4,7 +4,7 @@ structured as a python package. """ -from .assets import GenericAsset, ImgAsset +from .assets import GenericAsset, ImgAsset, TextAsset from .notifications import SponsorEmailNotificationTemplate from .sponsors import Sponsor, SponsorContact, SponsorBenefit from .benefits import BaseLogoPlacement, BaseTieredQuantity, BaseEmailTargetable, BenefitFeatureConfiguration, \ diff --git a/sponsors/models/assets.py b/sponsors/models/assets.py index d6b50288e..be93ab340 100644 --- a/sponsors/models/assets.py +++ b/sponsors/models/assets.py @@ -58,3 +58,15 @@ def __str__(self): class Meta: verbose_name = "Image Asset" verbose_name_plural = "Image Assets" + + +class TextAsset(GenericAsset): + text = models.TextField(default="") + + def __str__(self): + return f"Image asset: {self.internal_name}" + + class Meta: + verbose_name = "Image Asset" + verbose_name_plural = "Image Assets" + From acba56705970cc717689f2f935060b2692da7fbe Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 26 Oct 2021 12:34:25 -0300 Subject: [PATCH 09/22] Do not use UUID field as primary key Django polymorphic does not work with non-integer ids --- .../migrations/0054_auto_20211026_1432.py | 6 +----- .../migrations/0057_auto_20211026_1529.py | 19 +++++++++++++++++++ sponsors/models/assets.py | 3 ++- 3 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 sponsors/migrations/0057_auto_20211026_1529.py diff --git a/sponsors/migrations/0054_auto_20211026_1432.py b/sponsors/migrations/0054_auto_20211026_1432.py index 016aa90e1..1d0ecafc4 100644 --- a/sponsors/migrations/0054_auto_20211026_1432.py +++ b/sponsors/migrations/0054_auto_20211026_1432.py @@ -11,13 +11,9 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RemoveField( - model_name='genericasset', - name='id', - ), migrations.AddField( model_name='genericasset', name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + field=models.UUIDField(default=uuid.uuid4, editable=False, serialize=False), ), ] diff --git a/sponsors/migrations/0057_auto_20211026_1529.py b/sponsors/migrations/0057_auto_20211026_1529.py new file mode 100644 index 000000000..bd8ab17c7 --- /dev/null +++ b/sponsors/migrations/0057_auto_20211026_1529.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.24 on 2021-10-26 15:29 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0056_textasset'), + ] + + operations = [ + migrations.AlterField( + model_name='genericasset', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + ] diff --git a/sponsors/models/assets.py b/sponsors/models/assets.py index be93ab340..3eb96c9aa 100644 --- a/sponsors/models/assets.py +++ b/sponsors/models/assets.py @@ -25,7 +25,8 @@ class GenericAsset(PolymorphicModel): """ Base class used to add required assets to Sponsor or Sponsorship objects """ - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # UUID can't be the object ID because Polymorphic expects default django integer ID + uuid = models.UUIDField(default=uuid.uuid4, editable=False) # The next 3 fields are required by Django to enable and set up generic relations # pointing the asset to a Sponsor or Sponsorship object content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) From 97cf4902305e4e45fd669179d07c073b2bfd36d5 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 27 Oct 2021 16:38:27 -0300 Subject: [PATCH 10/22] Move benefit feature creation to specific method under feature cfg --- sponsors/models/benefits.py | 9 +++++++++ sponsors/models/sponsors.py | 4 +--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index c59d24885..5f5e8c7c8 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -138,6 +138,15 @@ def get_benefit_feature(self, **kwargs): def display_modifier(self, name, **kwargs): return name + def create_benefit_feature(self, sponsor_benefit, **kwargs): + """ + This methods persists a benefit feature from the configuration + """ + feature = self.get_benefit_feature(sponsor_benefit=sponsor_benefit, **kwargs) + if feature is not None: + feature.save() + return feature + class LogoPlacementConfiguration(BaseLogoPlacement, BenefitFeatureConfiguration): """ diff --git a/sponsors/models/sponsors.py b/sponsors/models/sponsors.py index db69fb703..25122373a 100644 --- a/sponsors/models/sponsors.py +++ b/sponsors/models/sponsors.py @@ -227,9 +227,7 @@ def new_copy(cls, benefit, **kwargs): # generate benefit features from benefit features configurations for feature_config in benefit.features_config.all(): - feature = feature_config.get_benefit_feature(sponsor_benefit=sponsor_benefit) - if feature is not None: - feature.save() + feature_config.create_benefit_feature(sponsor_benefit) return sponsor_benefit From afc4be52488cef3295da16c61c9ad921adfb34c6 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 27 Oct 2021 16:48:33 -0300 Subject: [PATCH 11/22] Remove duplicated tests --- sponsors/tests/test_models.py | 38 +---------------------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 56ad16d29..560f27aeb 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -64,17 +64,6 @@ def test_list_related_sponsorships(self): self.assertEqual(1, len(sponsorships)) self.assertIn(sponsor_benefit.sponsorship, sponsorships) - def test_name_for_display_without_specifying_package(self): - benefit = baker.make(SponsorshipBenefit, name='Benefit') - benefit_config = baker.make( - TieredQuantityConfiguration, - package__name='Package', - benefit=benefit, - ) - - self.assertEqual(benefit.name_for_display(), self.sponsorship_benefit.name) - self.assertFalse(benefit.has_tiers) - def test_name_for_display_without_specifying_package(self): benefit = baker.make(SponsorshipBenefit, name='Benefit') benefit_config = baker.make( @@ -269,30 +258,6 @@ def test_can_not_rollback_sponsorship_to_edit_if_contract_was_sent(self): self.assertEqual(1, Contract.objects.count()) - def test_rollback_sponsorship_to_edit(self): - sponsorship = Sponsorship.new(self.sponsor, self.benefits) - can_rollback_from = [ - Sponsorship.APPLIED, - Sponsorship.APPROVED, - Sponsorship.REJECTED, - ] - for status in can_rollback_from: - sponsorship.status = status - sponsorship.save() - sponsorship.refresh_from_db() - - sponsorship.rollback_to_editing() - - self.assertEqual(sponsorship.status, Sponsorship.APPLIED) - self.assertIsNone(sponsorship.approved_on) - self.assertIsNone(sponsorship.rejected_on) - - sponsorship.status = Sponsorship.FINALIZED - sponsorship.save() - sponsorship.refresh_from_db() - with self.assertRaises(InvalidStatusException): - sponsorship.rollback_to_editing() - def test_raise_exception_when_trying_to_create_sponsorship_for_same_sponsor(self): sponsorship = Sponsorship.new(self.sponsor, self.benefits) finalized_status = [Sponsorship.REJECTED, Sponsorship.FINALIZED] @@ -655,8 +620,7 @@ def test_sponsor_benefit_name_for_display(self): self.assertEqual(sponsor_benefit.name_for_display, f"{name} (10)") ########### -####### Email notification tests -########### +# Email notification tests class SponsorEmailNotificationTemplateTests(TestCase): def setUp(self): From bf4e2b73810a702aad7ea8aa415303e848c1b5e0 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 27 Oct 2021 17:21:12 -0300 Subject: [PATCH 12/22] Create empty ImgAsset during sponsorship creation --- sponsors/models/benefits.py | 19 ++++++++++++++++++ sponsors/tests/test_models.py | 36 +++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index 5f5e8c7c8..55f20745c 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -5,6 +5,7 @@ from django.db import models from polymorphic.models import PolymorphicModel +from sponsors.models.assets import ImgAsset from sponsors.models.enums import PublisherChoices, LogoPlacementChoices, AssetsRelatedTo @@ -42,6 +43,8 @@ class Meta: class BaseRequiredAsset(models.Model): + ASSET_CLASS = None + related_to = models.CharField( max_length=30, choices=[(c.value, c.name.replace("_", " ").title()) for c in AssetsRelatedTo], @@ -56,11 +59,27 @@ class BaseRequiredAsset(models.Model): db_index=True, ) + def create_benefit_feature(self, sponsor_benefit, **kwargs): + if not self.ASSET_CLASS: + raise NotImplementedError("Subclasses of BaseRequiredAsset must define an ASSET_CLASS attribute.") + + # Super: BenefitFeatureConfiguration.create_benefit_feature + benefit_feature = super().create_benefit_feature(sponsor_benefit, **kwargs) + asset = self.ASSET_CLASS( + content_object=sponsor_benefit.sponsorship.sponsor, + internal_name=self.internal_name, + ) + asset.save() + + return benefit_feature + class Meta: abstract = True class BaseRequiredImgAsset(BaseRequiredAsset): + ASSET_CLASS = ImgAsset + min_width = models.PositiveIntegerField() max_width = models.PositiveIntegerField() min_height = models.PositiveIntegerField() diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 560f27aeb..0cca4f5e9 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -18,14 +18,14 @@ SponsorshipBenefit, SponsorshipPackage, TieredQuantity, - TieredQuantityConfiguration + TieredQuantityConfiguration, RequiredImgAssetConfiguration, RequiredImgAsset, ImgAsset ) from ..exceptions import ( SponsorWithExistingApplicationException, SponsorshipInvalidDateRangeException, InvalidStatusException, ) -from sponsors.models.enums import PublisherChoices, LogoPlacementChoices +from sponsors.models.enums import PublisherChoices, LogoPlacementChoices, AssetsRelatedTo class SponsorshipBenefitModelTests(TestCase): @@ -754,3 +754,35 @@ def test_display_modifier_adds_quantity_to_the_name(self): placement = baker.make(TieredQuantity, quantity=10) name = 'Benefit' self.assertEqual(placement.display_modifier(name), 'Benefit (10)') + + +class RequiredImgAssetConfigurationTests(TestCase): + + def setUp(self): + self.sponsor_benefit = baker.make(SponsorBenefit, sponsorship__sponsor__name='Foo') + self.config = baker.make( + RequiredImgAssetConfiguration, + related_to=AssetsRelatedTo.SPONSOR.value, + internal_name="config_name", + ) + + def test_get_benefit_feature_respecting_configuration(self): + benefit_feature = self.config.get_benefit_feature(sponsor_benefit=self.sponsor_benefit) + + self.assertIsInstance(benefit_feature, RequiredImgAsset) + self.assertEqual(benefit_feature.max_width, self.config.max_width) + self.assertEqual(benefit_feature.min_width, self.config.min_width) + self.assertEqual(benefit_feature.max_height, self.config.max_height) + self.assertEqual(benefit_feature.min_height, self.config.min_height) + + def test_create_benefit_feature_and_sponsor_generic_img_assets(self): + sponsor = self.sponsor_benefit.sponsorship.sponsor + + feature = self.config.create_benefit_feature(self.sponsor_benefit) + asset = ImgAsset.objects.get() + + self.assertIsInstance(feature, RequiredImgAsset) + self.assertTrue(feature.pk) + self.assertEqual(self.config.internal_name, asset.internal_name) + self.assertEqual(sponsor, asset.content_object) + self.assertFalse(asset.image.name) From 1bb967e08fe17b503d880c4ab8c0298826d100cc Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 27 Oct 2021 17:25:38 -0300 Subject: [PATCH 13/22] Create empty TextAsset during sponsorship creation --- sponsors/models/benefits.py | 4 +++- sponsors/tests/test_models.py | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index 55f20745c..99220d843 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -5,7 +5,7 @@ from django.db import models from polymorphic.models import PolymorphicModel -from sponsors.models.assets import ImgAsset +from sponsors.models.assets import ImgAsset, TextAsset from sponsors.models.enums import PublisherChoices, LogoPlacementChoices, AssetsRelatedTo @@ -90,6 +90,8 @@ class Meta: class BaseRequiredTextAsset(BaseRequiredAsset): + ASSET_CLASS = TextAsset + label = models.CharField( max_length=256, help_text="What's the title used to display the text input to the sponsor?" diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 0cca4f5e9..9e11ccfcd 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -18,7 +18,8 @@ SponsorshipBenefit, SponsorshipPackage, TieredQuantity, - TieredQuantityConfiguration, RequiredImgAssetConfiguration, RequiredImgAsset, ImgAsset + TieredQuantityConfiguration, RequiredImgAssetConfiguration, RequiredImgAsset, ImgAsset, + RequiredTextAssetConfiguration, RequiredTextAsset, TextAsset ) from ..exceptions import ( SponsorWithExistingApplicationException, @@ -786,3 +787,34 @@ def test_create_benefit_feature_and_sponsor_generic_img_assets(self): self.assertEqual(self.config.internal_name, asset.internal_name) self.assertEqual(sponsor, asset.content_object) self.assertFalse(asset.image.name) + + +class RequiredTextAssetConfigurationTests(TestCase): + + def setUp(self): + self.sponsor_benefit = baker.make(SponsorBenefit, sponsorship__sponsor__name='Foo') + self.config = baker.make( + RequiredTextAssetConfiguration, + related_to=AssetsRelatedTo.SPONSOR.value, + internal_name="config_name", + _fill_optional=True, + ) + + def test_get_benefit_feature_respecting_configuration(self): + benefit_feature = self.config.get_benefit_feature(sponsor_benefit=self.sponsor_benefit) + + self.assertIsInstance(benefit_feature, RequiredTextAsset) + self.assertEqual(benefit_feature.label, self.config.label) + self.assertEqual(benefit_feature.help_text, self.config.help_text) + + def test_create_benefit_feature_and_sponsor_generic_text_asset(self): + sponsor = self.sponsor_benefit.sponsorship.sponsor + + feature = self.config.create_benefit_feature(self.sponsor_benefit) + asset = TextAsset.objects.get() + + self.assertIsInstance(feature, RequiredTextAsset) + self.assertTrue(feature.pk) + self.assertEqual(self.config.internal_name, asset.internal_name) + self.assertEqual(sponsor, asset.content_object) + self.assertFalse(asset.text) From 5d61930af9566abe488cc5da48b1731d4d62a1c2 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 27 Oct 2021 17:36:21 -0300 Subject: [PATCH 14/22] Check if asset relates to sponsor or sponsorship before creating it --- sponsors/models/benefits.py | 8 ++++++-- sponsors/tests/test_models.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index 99220d843..c13dd99f1 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -65,9 +65,13 @@ def create_benefit_feature(self, sponsor_benefit, **kwargs): # Super: BenefitFeatureConfiguration.create_benefit_feature benefit_feature = super().create_benefit_feature(sponsor_benefit, **kwargs) + + content_object = sponsor_benefit.sponsorship + if self.related_to == AssetsRelatedTo.SPONSOR.value: + content_object = sponsor_benefit.sponsorship.sponsor + asset = self.ASSET_CLASS( - content_object=sponsor_benefit.sponsorship.sponsor, - internal_name=self.internal_name, + content_object=content_object, internal_name=self.internal_name, ) asset.save() diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 9e11ccfcd..908bd34eb 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -818,3 +818,13 @@ def test_create_benefit_feature_and_sponsor_generic_text_asset(self): self.assertEqual(self.config.internal_name, asset.internal_name) self.assertEqual(sponsor, asset.content_object) self.assertFalse(asset.text) + + def test_relate_asset_with_sponsorship_respecting_config(self): + self.config.related_to = AssetsRelatedTo.SPONSORSHIP.value + self.config.save() + sponsorship = self.sponsor_benefit.sponsorship + + self.config.create_benefit_feature(self.sponsor_benefit) + + asset = TextAsset.objects.get() + self.assertEqual(sponsorship, asset.content_object) From 1a5a879e403b0eeff7899c17244e4738a65e0d1d Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 27 Oct 2021 18:04:26 -0300 Subject: [PATCH 15/22] Add generic relation to iter over all assets from sponsor/sponsorship models --- sponsors/models/sponsors.py | 3 +++ sponsors/models/sponsorship.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/sponsors/models/sponsors.py b/sponsors/models/sponsors.py index 25122373a..a3eeb83c0 100644 --- a/sponsors/models/sponsors.py +++ b/sponsors/models/sponsors.py @@ -6,8 +6,10 @@ from django.db import models from django_countries.fields import CountryField from ordered_model.models import OrderedModel +from django.contrib.contenttypes.fields import GenericRelation from cms.models import ContentManageable +from sponsors.models.assets import GenericAsset from sponsors.models.managers import SponsorContactQuerySet @@ -67,6 +69,7 @@ class Sponsor(ContentManageable): verbose_name="Zip/Postal Code", max_length=64, default="" ) country = CountryField(default="") + assets = GenericRelation(GenericAsset) class Meta: verbose_name = "sponsor" diff --git a/sponsors/models/sponsorship.py b/sponsors/models/sponsorship.py index 13eef1a0a..44ac484aa 100644 --- a/sponsors/models/sponsorship.py +++ b/sponsors/models/sponsorship.py @@ -5,6 +5,7 @@ from itertools import chain from django.conf import settings +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ObjectDoesNotExist from django.db import models, transaction from django.db.models import Subquery, Sum @@ -18,6 +19,7 @@ from sponsors.exceptions import SponsorWithExistingApplicationException, InvalidStatusException, \ SponsorshipInvalidDateRangeException +from sponsors.models.assets import GenericAsset from sponsors.models.managers import SponsorshipPackageManager, SponsorshipBenefitManager, SponsorshipQuerySet from sponsors.models.benefits import TieredQuantityConfiguration from sponsors.models.sponsors import SponsorBenefit @@ -137,6 +139,8 @@ class Sponsorship(models.Model): package = models.ForeignKey(SponsorshipPackage, null=True, on_delete=models.SET_NULL) sponsorship_fee = models.PositiveIntegerField(null=True, blank=True) + assets = GenericRelation(GenericAsset) + class Meta: permissions = [ ("sponsor_publisher", "Can access sponsor placement API"), From 879cc0e7c3d65bc4834df9479eecc14a0186922e Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 29 Oct 2021 11:19:19 -0300 Subject: [PATCH 16/22] Assets base models Meta should inherit too --- sponsors/models/benefits.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index c13dd99f1..14ddfbe0a 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -89,7 +89,7 @@ class BaseRequiredImgAsset(BaseRequiredAsset): min_height = models.PositiveIntegerField() max_height = models.PositiveIntegerField() - class Meta: + class Meta(BaseRequiredAsset.Meta): abstract = True @@ -107,7 +107,7 @@ class BaseRequiredTextAsset(BaseRequiredAsset): blank=True ) - class Meta: + class Meta(BaseRequiredAsset.Meta): abstract = True From bc98fe5c72ea5fff5084134f1673c4812ac3d296 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 29 Oct 2021 11:30:39 -0300 Subject: [PATCH 17/22] Prevent same required asset from being created twice --- .../migrations/0058_auto_20211029_1427.py | 41 +++++++++++++++++++ sponsors/models/benefits.py | 18 +++++--- sponsors/tests/test_models.py | 6 +++ 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 sponsors/migrations/0058_auto_20211029_1427.py diff --git a/sponsors/migrations/0058_auto_20211029_1427.py b/sponsors/migrations/0058_auto_20211029_1427.py new file mode 100644 index 000000000..af2b110d1 --- /dev/null +++ b/sponsors/migrations/0058_auto_20211029_1427.py @@ -0,0 +1,41 @@ +# Generated by Django 2.2.24 on 2021-10-29 14:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0057_auto_20211026_1529'), + ] + + operations = [ + migrations.AlterField( + model_name='requiredimgasset', + name='internal_name', + field=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'), + ), + migrations.AlterField( + model_name='requiredimgassetconfiguration', + name='internal_name', + field=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'), + ), + migrations.AlterField( + model_name='requiredtextasset', + name='internal_name', + field=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'), + ), + migrations.AlterField( + model_name='requiredtextassetconfiguration', + name='internal_name', + field=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'), + ), + migrations.AddConstraint( + model_name='requiredimgassetconfiguration', + constraint=models.UniqueConstraint(fields=('internal_name',), name='uniq_img_asset_cfg'), + ), + migrations.AddConstraint( + model_name='requiredtextassetconfiguration', + constraint=models.UniqueConstraint(fields=('internal_name',), name='uniq_text_asset_cfg'), + ), + ] diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index 14ddfbe0a..9d265df89 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -2,7 +2,8 @@ This module holds models related to benefits features and configurations """ -from django.db import models +from django.db import models, IntegrityError, transaction +from django.db.models import UniqueConstraint from polymorphic.models import PolymorphicModel from sponsors.models.assets import ImgAsset, TextAsset @@ -55,7 +56,7 @@ class BaseRequiredAsset(models.Model): max_length=128, verbose_name="Internal Name", help_text="Unique name used internally to control if the sponsor/sponsorship already has the asset", - unique=True, + unique=False, db_index=True, ) @@ -70,10 +71,12 @@ def create_benefit_feature(self, sponsor_benefit, **kwargs): if self.related_to == AssetsRelatedTo.SPONSOR.value: content_object = sponsor_benefit.sponsorship.sponsor - asset = self.ASSET_CLASS( - content_object=content_object, internal_name=self.internal_name, - ) - asset.save() + asset_qs = content_object.assets.filter(internal_name=self.internal_name) + if not asset_qs.exists(): + asset = self.ASSET_CLASS( + content_object=content_object, internal_name=self.internal_name, + ) + asset.save() return benefit_feature @@ -235,9 +238,11 @@ def __str__(self): class RequiredImgAssetConfiguration(BaseRequiredImgAsset, BenefitFeatureConfiguration): + class Meta(BaseRequiredImgAsset.Meta, BenefitFeatureConfiguration.Meta): verbose_name = "Require Image Configuration" verbose_name_plural = "Require Image Configurations" + constraints = [UniqueConstraint(fields=["internal_name"], name="uniq_img_asset_cfg")] def __str__(self): return f"Require image configuration" @@ -251,6 +256,7 @@ class RequiredTextAssetConfiguration(BaseRequiredTextAsset, BenefitFeatureConfig class Meta(BaseRequiredTextAsset.Meta, BenefitFeatureConfiguration.Meta): verbose_name = "Require Text Configuration" verbose_name_plural = "Require Text Configurations" + constraints = [UniqueConstraint(fields=["internal_name"], name="uniq_text_asset_cfg")] def __str__(self): return f"Require text configuration" diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 908bd34eb..dc8a19b8a 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -828,3 +828,9 @@ def test_relate_asset_with_sponsorship_respecting_config(self): asset = TextAsset.objects.get() self.assertEqual(sponsorship, asset.content_object) + + def test_cant_create_same_asset_twice(self): + self.config.create_benefit_feature(self.sponsor_benefit) + self.sponsor_benefit.refresh_from_db() + self.config.create_benefit_feature(self.sponsor_benefit) + self.assertEqual(1, TextAsset.objects.count()) From 74604dc11fbe297fc0915c82642330fa2edadb0c Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 29 Oct 2021 11:47:39 -0300 Subject: [PATCH 18/22] Optimizes query to list sponsorship benefits --- sponsors/admin.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index a4c89ccd0..b8857cfa6 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -146,6 +146,7 @@ class SponsorBenefitInline(admin.TabularInline): form = SponsorBenefitAdminInlineForm fields = ["sponsorship_benefit", "benefit_internal_value"] extra = 0 + max_num = 0 def has_add_permission(self, request, obj=None): has_add_permission = super().has_add_permission(request, obj=obj) @@ -165,6 +166,10 @@ def has_delete_permission(self, request, obj=None): return True return obj.open_for_editing + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + return qs.select_related("sponsorship_benefit__program", "program") + class TargetableEmailBenefitsFilter(admin.SimpleListFilter): title = "targetable email benefits" @@ -257,7 +262,7 @@ class SponsorshipAdmin(admin.ModelAdmin): def get_queryset(self, *args, **kwargs): qs = super().get_queryset(*args, **kwargs) - return qs.select_related("sponsor", "package") + return qs.select_related("sponsor", "package", "submited_by") def send_notifications(self, request, queryset): return views_admin.send_sponsorship_notifications_action(self, request, queryset) From 7b501c8098f3a8f1a31f055418601d8c59aeea3a Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 29 Oct 2021 12:08:53 -0300 Subject: [PATCH 19/22] List sponsorship assets under sponsor/sponsorship detail admin --- sponsors/admin.py | 41 ++++++++++++++++--- .../migrations/0059_auto_20211029_1503.py | 17 ++++++++ sponsors/models/assets.py | 17 ++++++-- 3 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 sponsors/migrations/0059_auto_20211029_1503.py diff --git a/sponsors/admin.py b/sponsors/admin.py index b8857cfa6..3f5983d71 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.admin import GenericTabularInline from ordered_model.admin import OrderedModelAdmin from polymorphic.admin import PolymorphicInlineSupportMixin, StackedPolymorphicInline @@ -5,7 +6,7 @@ from django.template import Context, Template from django.contrib import admin from django.contrib.humanize.templatetags.humanize import intcomma -from django.urls import path, reverse +from django.urls import path, reverse, resolve from django.utils.functional import cached_property from django.utils.html import mark_safe @@ -16,6 +17,25 @@ from cms.admin import ContentManageableModelAdmin +class AssetsInline(GenericTabularInline): + model = GenericAsset + extra = 0 + max_num = 0 + has_delete_permission = lambda self, request, obj: False + readonly_fields = ["internal_name", "user_submitted_info", "value"] + + def value(self, request, obj=None): + if not obj or not obj.value: + return "" + return obj.value + value.short_description = "Submitted information" + + def user_submitted_info(self, request, obj=None): + return bool(self.value(request, obj)) + user_submitted_info.short_description = "Fullfilled data?" + user_submitted_info.boolean = True + + @admin.register(SponsorshipProgram) class SponsorshipProgramAdmin(OrderedModelAdmin): ordering = ("order",) @@ -137,7 +157,7 @@ class SponsorContactInline(admin.TabularInline): @admin.register(Sponsor) class SponsorAdmin(ContentManageableModelAdmin): - inlines = [SponsorContactInline] + inlines = [SponsorContactInline, AssetsInline] search_fields = ["name"] @@ -183,7 +203,7 @@ def benefits(self): def lookups(self, request, model_admin): return [ - (k, b.name) for k, b in self.benefits.items() + (k, b.name) for k, b in self.benefits.items() ] def queryset(self, request, queryset): @@ -200,7 +220,7 @@ def queryset(self, request, queryset): class SponsorshipAdmin(admin.ModelAdmin): change_form_template = "sponsors/admin/sponsorship_change_form.html" form = SponsorshipReviewAdminForm - inlines = [SponsorBenefitInline] + inlines = [SponsorBenefitInline, AssetsInline] search_fields = ["sponsor__name"] list_display = [ "sponsor", @@ -304,6 +324,7 @@ def get_estimated_cost(self, obj): cost = intcomma(obj.estimated_cost) html = f"{cost} USD
Important: {msg}" return mark_safe(html) + get_estimated_cost.short_description = "Estimated cost" def get_contract(self, obj): @@ -312,6 +333,7 @@ def get_contract(self, obj): url = reverse("admin:sponsors_contract_change", args=[obj.contract.pk]) html = f"{obj.contract}" return mark_safe(html) + get_contract.short_description = "Contract" def get_urls(self): @@ -344,14 +366,17 @@ def get_urls(self): def get_sponsor_name(self, obj): return obj.sponsor.name + get_sponsor_name.short_description = "Name" def get_sponsor_description(self, obj): return obj.sponsor.description + get_sponsor_description.short_description = "Description" def get_sponsor_landing_page_url(self, obj): return obj.sponsor.landing_page_url + get_sponsor_landing_page_url.short_description = "Landing Page URL" def get_sponsor_web_logo(self, obj): @@ -360,6 +385,7 @@ def get_sponsor_web_logo(self, obj): context = Context({'sponsor': obj.sponsor}) html = template.render(context) return mark_safe(html) + get_sponsor_web_logo.short_description = "Web Logo" def get_sponsor_print_logo(self, obj): @@ -371,10 +397,12 @@ def get_sponsor_print_logo(self, obj): context = Context({'img': img}) html = template.render(context) return mark_safe(html) if html else "---" + get_sponsor_print_logo.short_description = "Print Logo" def get_sponsor_primary_phone(self, obj): return obj.sponsor.primary_phone + get_sponsor_primary_phone.short_description = "Primary Phone" def get_sponsor_mailing_address(self, obj): @@ -393,6 +421,7 @@ def get_sponsor_mailing_address(self, obj): html += f"

{mail_row}

" html += f"

{sponsor.postal_code}

" return mark_safe(html) + get_sponsor_mailing_address.short_description = "Mailing/Billing Address" def get_sponsor_contacts(self, obj): @@ -413,6 +442,7 @@ def get_sponsor_contacts(self, obj): ) html += "" return mark_safe(html) + get_sponsor_contacts.short_description = "Contacts" def rollback_to_editing_view(self, request, pk): @@ -536,8 +566,8 @@ def document_link(self, obj): if url and msg: html = f'{msg}' return mark_safe(html) - document_link.short_description = "Contract document" + document_link.short_description = "Contract document" def get_sponsorship_url(self, obj): if not obj.sponsorship: @@ -545,6 +575,7 @@ def get_sponsorship_url(self, obj): url = reverse("admin:sponsors_sponsorship_change", args=[obj.sponsorship.pk]) html = f"{obj.sponsorship}" return mark_safe(html) + get_sponsorship_url.short_description = "Sponsorship" def get_urls(self): diff --git a/sponsors/migrations/0059_auto_20211029_1503.py b/sponsors/migrations/0059_auto_20211029_1503.py new file mode 100644 index 000000000..db3c22fb9 --- /dev/null +++ b/sponsors/migrations/0059_auto_20211029_1503.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.24 on 2021-10-29 15:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0058_auto_20211029_1427'), + ] + + operations = [ + migrations.AlterModelOptions( + name='textasset', + options={'verbose_name': 'Text Asset', 'verbose_name_plural': 'Text Assets'}, + ), + ] diff --git a/sponsors/models/assets.py b/sponsors/models/assets.py index 3eb96c9aa..e28cb5cdb 100644 --- a/sponsors/models/assets.py +++ b/sponsors/models/assets.py @@ -45,6 +45,10 @@ class Meta: verbose_name_plural = "Assets" unique_together = ["content_type", "object_id", "internal_name"] + @property + def value(self): + return None + class ImgAsset(GenericAsset): image = models.ImageField( @@ -60,14 +64,21 @@ class Meta: verbose_name = "Image Asset" verbose_name_plural = "Image Assets" + @property + def value(self): + return self.image + class TextAsset(GenericAsset): text = models.TextField(default="") def __str__(self): - return f"Image asset: {self.internal_name}" + return f"Text asset: {self.internal_name}" class Meta: - verbose_name = "Image Asset" - verbose_name_plural = "Image Assets" + verbose_name = "Text Asset" + verbose_name_plural = "Text Assets" + @property + def value(self): + return self.text From 30a8672f04ff619eab9d81713b522932413abe1b Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Mon, 1 Nov 2021 11:18:34 -0300 Subject: [PATCH 20/22] Add extra card on sponsorship detail page to link to assets form --- templates/users/sponsorship_detail.html | 14 ++++++++++++++ users/views.py | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/templates/users/sponsorship_detail.html b/templates/users/sponsorship_detail.html index 3faebf07c..696e39c4c 100644 --- a/templates/users/sponsorship_detail.html +++ b/templates/users/sponsorship_detail.html @@ -86,6 +86,20 @@

Application Data

+ + {% if assets %} +
+

Required Assets

+ You've selected benefits which requires extra assets (logos, slides etc). +
+
+ {% if not fullfilled_assets %} +

You still have to provide required information for this sponsorship. Please, click here to fullfill your application.

+ {% else %} +

Click here to review or edit your extra information.

+ {% endif %} +
+ {% endif %}
diff --git a/users/views.py b/users/views.py index 1053812d3..43680f8fa 100644 --- a/users/views.py +++ b/users/views.py @@ -236,7 +236,11 @@ def get_queryset(self): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context["sponsor"] = context["sponsorship"].sponsor + sponsorship = context["sponsorship"] + assets = list(sponsorship.assets.all()) + list(sponsorship.sponsor.assets.all()) + context["sponsor"] = sponsorship.sponsor + context["assets"] = assets + context["fullfilled_assets"] = all([bool(asset.value) for asset in assets]) return context From 60ac592a5ea11759c346d6207135aeb490a0ca38 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 2 Nov 2021 11:36:18 -0300 Subject: [PATCH 21/22] Revert "Add extra card on sponsorship detail page to link to assets form" This reverts commit 30a8672f04ff619eab9d81713b522932413abe1b. --- templates/users/sponsorship_detail.html | 14 -------------- users/views.py | 6 +----- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/templates/users/sponsorship_detail.html b/templates/users/sponsorship_detail.html index 696e39c4c..3faebf07c 100644 --- a/templates/users/sponsorship_detail.html +++ b/templates/users/sponsorship_detail.html @@ -86,20 +86,6 @@

Application Data

- - {% if assets %} -
-

Required Assets

- You've selected benefits which requires extra assets (logos, slides etc). -
-
- {% if not fullfilled_assets %} -

You still have to provide required information for this sponsorship. Please, click here to fullfill your application.

- {% else %} -

Click here to review or edit your extra information.

- {% endif %} -
- {% endif %}
diff --git a/users/views.py b/users/views.py index 43680f8fa..1053812d3 100644 --- a/users/views.py +++ b/users/views.py @@ -236,11 +236,7 @@ def get_queryset(self): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - sponsorship = context["sponsorship"] - assets = list(sponsorship.assets.all()) + list(sponsorship.sponsor.assets.all()) - context["sponsor"] = sponsorship.sponsor - context["assets"] = assets - context["fullfilled_assets"] = all([bool(asset.value) for asset in assets]) + context["sponsor"] = context["sponsorship"].sponsor return context From 083ca7135ccd500a45675d7aeb9e17dba58fd246 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 3 Nov 2021 10:12:42 -0300 Subject: [PATCH 22/22] Update the docs of the sponsors app to give a broader vision of the models structure --- docs/source/administration.rst | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/source/administration.rst b/docs/source/administration.rst index aa4f33718..254c36a20 100644 --- a/docs/source/administration.rst +++ b/docs/source/administration.rst @@ -91,12 +91,21 @@ The jobs application is using to display Python jobs on the site. The data items Sponsors -------- -The Sponsors app is a place to store PSF Sponsors. Sponsors have to be associated to a Company model from -the companies app. If they are marked as `is_published` they will be shown on the main sponsor page which -is located at /sponsors/. +The Sponsors app is a place to store PSF Sponsors and Sponsorships. This is the most complex app in the +project due to the multiple possibilities on how to configure a sponsorship and, to support this, the +app has a lot of models that are grouped by context. Here's a list of the group of models and what do +they represent: + +:sponsorship.py: The `Sponsorship` model and all the related information to configure a new sponsorship + appplication like programs, packages and benefits; +:benefits.py: List models that are used to configure benefits. Here you'll find models that forces a + benefit to have an asset or controls it maximum quantity; +:assets.py: Models that are used to configure the type of assets that a benefit can have; +:sponsors.py: Has the `Sponsor` model and all related information such as their contacts and benefits; +:notifications.py: Any type of sponsor notification that's configurable via admin; +:contract.py: The `Contract` model which is used to generate the final contract document and other + support models; -If a Sponsor is marked as `featured` they will be included in the sponsor rotation on the main PSF landing -page. In the fourth "Sponsors" column. Events ------