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 ------ diff --git a/sponsors/admin.py b/sponsors/admin.py index bd1100eee..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,33 +6,36 @@ 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 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 sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm, RequiredImgAssetConfigurationForm 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",) @@ -55,11 +59,20 @@ class EmailTargetableConfigurationInline(StackedPolymorphicInline.Child): def display(self, obj): return "Enabled" + class RequiredImgAssetConfigurationInline(StackedPolymorphicInline.Child): + model = RequiredImgAssetConfiguration + form = RequiredImgAssetConfigurationForm + + class RequiredTextAssetConfigurationInline(StackedPolymorphicInline.Child): + model = RequiredTextAssetConfiguration + model = BenefitFeatureConfiguration child_inlines = [ LogoPlacementConfigurationInline, TieredQuantityConfigurationInline, EmailTargetableConfigurationInline, + RequiredImgAssetConfigurationInline, + RequiredTextAssetConfigurationInline, ] @@ -144,7 +157,7 @@ class SponsorContactInline(admin.TabularInline): @admin.register(Sponsor) class SponsorAdmin(ContentManageableModelAdmin): - inlines = [SponsorContactInline] + inlines = [SponsorContactInline, AssetsInline] search_fields = ["name"] @@ -153,6 +166,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) @@ -172,6 +186,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" @@ -185,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): @@ -202,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", @@ -264,7 +282,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) @@ -306,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): @@ -314,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): @@ -346,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): @@ -362,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): @@ -373,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): @@ -395,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): @@ -415,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): @@ -538,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: @@ -547,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/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__" 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. '), + ), + ] 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/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/migrations/0054_auto_20211026_1432.py b/sponsors/migrations/0054_auto_20211026_1432.py new file mode 100644 index 000000000..1d0ecafc4 --- /dev/null +++ b/sponsors/migrations/0054_auto_20211026_1432.py @@ -0,0 +1,19 @@ +# 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.AddField( + model_name='genericasset', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, serialize=False), + ), + ] 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/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/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/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/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/__init__.py b/sponsors/models/__init__.py index bdbce66ab..d7d2759be 100644 --- a/sponsors/models/__init__.py +++ b/sponsors/models/__init__.py @@ -4,10 +4,12 @@ structured as a python package. """ +from .assets import GenericAsset, ImgAsset, TextAsset from .notifications import SponsorEmailNotificationTemplate 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, \ + RequiredTextAssetConfiguration, RequiredTextAsset 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 new file mode 100644 index 000000000..e28cb5cdb --- /dev/null +++ b/sponsors/models/assets.py @@ -0,0 +1,84 @@ +""" +This module holds models to store generic assets +from Sponsors or Sponsorships +""" +import uuid +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-app-assets" + ext = "".join(Path(filename).suffixes) + name = f"{instance.uuid}{ext}" + return f"{directory}{name}{ext}" + + +class GenericAsset(PolymorphicModel): + """ + Base class used to add required assets to Sponsor or Sponsorship objects + """ + # 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) + 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"] + + @property + def value(self): + return None + + +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" + + @property + def value(self): + return self.image + + +class TextAsset(GenericAsset): + text = models.TextField(default="") + + def __str__(self): + return f"Text asset: {self.internal_name}" + + class Meta: + verbose_name = "Text Asset" + verbose_name_plural = "Text Assets" + + @property + def value(self): + return self.text diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index 27f40ed4d..9d265df89 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -2,15 +2,16 @@ This module holds models related to benefits features and configurations """ -from django.db import models - -######################################## -# Benefit features abstract classes +from django.db import models, IntegrityError, transaction +from django.db.models import UniqueConstraint from polymorphic.models import PolymorphicModel -from sponsors.models.enums import PublisherChoices, LogoPlacementChoices +from sponsors.models.assets import ImgAsset, TextAsset +from sponsors.models.enums import PublisherChoices, LogoPlacementChoices, AssetsRelatedTo +######################################## +# Benefit features abstract classes class BaseLogoPlacement(models.Model): publisher = models.CharField( max_length=30, @@ -42,6 +43,77 @@ class Meta: abstract = True +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], + 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=False, + 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) + + content_object = sponsor_benefit.sponsorship + if self.related_to == AssetsRelatedTo.SPONSOR.value: + content_object = sponsor_benefit.sponsorship.sponsor + + 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 + + class Meta: + abstract = True + + +class BaseRequiredImgAsset(BaseRequiredAsset): + ASSET_CLASS = ImgAsset + + min_width = models.PositiveIntegerField() + max_width = models.PositiveIntegerField() + min_height = models.PositiveIntegerField() + max_height = models.PositiveIntegerField() + + class Meta(BaseRequiredAsset.Meta): + abstract = True + + +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?" + ) + help_text = models.CharField( + max_length=256, + help_text="Any helper comment on how the input should be populated", + default="", + blank=True + ) + + class Meta(BaseRequiredAsset.Meta): + abstract = True + + ###################################################### # SponsorshipBenefit features configuration models class BenefitFeatureConfiguration(PolymorphicModel): @@ -94,6 +166,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): """ @@ -156,6 +237,35 @@ 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" + constraints = [UniqueConstraint(fields=["internal_name"], name="uniq_img_asset_cfg")] + + def __str__(self): + return f"Require image configuration" + + @property + 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" + constraints = [UniqueConstraint(fields=["internal_name"], name="uniq_text_asset_cfg")] + + def __str__(self): + return f"Require text configuration" + + @property + def benefit_feature_class(self): + return RequiredTextAsset + + #################################### # SponsorBenefit features models class BenefitFeature(PolymorphicModel): @@ -213,3 +323,21 @@ 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" + 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" 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" diff --git a/sponsors/models/sponsors.py b/sponsors/models/sponsors.py index db69fb703..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" @@ -227,9 +230,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 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"), diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 56ad16d29..dc8a19b8a 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -18,14 +18,15 @@ SponsorshipBenefit, SponsorshipPackage, TieredQuantity, - TieredQuantityConfiguration + TieredQuantityConfiguration, RequiredImgAssetConfiguration, RequiredImgAsset, ImgAsset, + RequiredTextAssetConfiguration, RequiredTextAsset, TextAsset ) from ..exceptions import ( SponsorWithExistingApplicationException, SponsorshipInvalidDateRangeException, InvalidStatusException, ) -from sponsors.models.enums import PublisherChoices, LogoPlacementChoices +from sponsors.models.enums import PublisherChoices, LogoPlacementChoices, AssetsRelatedTo class SponsorshipBenefitModelTests(TestCase): @@ -64,17 +65,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 +259,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 +621,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): @@ -790,3 +755,82 @@ 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) + + +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) + + 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) + + 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())