diff --git a/base-requirements.txt b/base-requirements.txt index f0bd6c1b6..3e0055de9 100644 --- a/base-requirements.txt +++ b/base-requirements.txt @@ -43,3 +43,4 @@ django-countries==6.1.3 xhtml2pdf==0.2.5 django-easy-pdf==0.1.1 num2words==0.5.10 +django-polymorphic==2.1.2 diff --git a/pydotorg/settings/base.py b/pydotorg/settings/base.py index fd8b7e187..c4dec4b06 100644 --- a/pydotorg/settings/base.py +++ b/pydotorg/settings/base.py @@ -191,6 +191,7 @@ 'rest_framework', 'rest_framework.authtoken', 'django_filters', + 'polymorphic', ] # Fixtures diff --git a/sponsors/admin.py b/sponsors/admin.py index 62a7324bc..2863847a3 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -1,4 +1,5 @@ from ordered_model.admin import OrderedModelAdmin +from polymorphic.admin import PolymorphicInlineSupportMixin, StackedPolymorphicInline from django.contrib import admin from django.contrib.humanize.templatetags.humanize import intcomma @@ -15,6 +16,8 @@ SponsorBenefit, LegalClause, Contract, + BenefitFeatureConfiguration, + LogoPlacementConfiguration, ) from sponsors import views_admin from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm @@ -30,8 +33,19 @@ class SponsorshipProgramAdmin(OrderedModelAdmin): ] +class BenefitFeatureConfigurationInline(StackedPolymorphicInline): + class LogoPlacementConfigurationInline(StackedPolymorphicInline.Child): + model = LogoPlacementConfiguration + + model = BenefitFeatureConfiguration + child_inlines = [ + LogoPlacementConfigurationInline + ] + + @admin.register(SponsorshipBenefit) -class SponsorshipBenefitAdmin(OrderedModelAdmin): +class SponsorshipBenefitAdmin(PolymorphicInlineSupportMixin, OrderedModelAdmin): + inlines = [BenefitFeatureConfigurationInline] ordering = ("program", "order") list_display = [ "program", diff --git a/sponsors/enums.py b/sponsors/enums.py new file mode 100644 index 000000000..1387bbdac --- /dev/null +++ b/sponsors/enums.py @@ -0,0 +1,18 @@ +from enum import Enum + + +class LogoPlacementChoices(Enum): + SIDEBAR = "sidebar" + SPONSORS_PAGE = "sponsors" + JOBS = "jobs" + BLOG = "blogpost" + FOOTER = "footer" + DOCS = "docs" + DOWNLOAD_PAGE = "download" + DEV_GUIDE = "devguide" + +class PublisherChoices(Enum): + FOUNDATION = "psf" + PYCON = "pycon" + PYPI = "pypi" + CORE_DEV = "core" diff --git a/sponsors/management/commands/gen_sponsors_csv.py b/sponsors/management/commands/gen_sponsors_csv.py new file mode 100644 index 000000000..48e19f34d --- /dev/null +++ b/sponsors/management/commands/gen_sponsors_csv.py @@ -0,0 +1,89 @@ +import csv +from django.core.management import BaseCommand +from django.template.defaultfilters import slugify + +from sponsors.models import Sponsorship, Contract + +# This command works as a faster entrypoint to generate a CSV file +# with all the sponsor data required by the sponsor placement server +# Python.org will use to display sponsors' logo. This command can +# become an optional one once such operation is enabled via admin. + +class Command(BaseCommand): + """ + Generate CSV with sponsors data to be sent to sponsor placement server + """ + help = "Generate CSV with sponsors data to be sent to sponsor placement server" + + def handle(self, **options): + qs = Sponsorship.objects.finalized().select_related('sponsor') + if not qs.exists(): + print("There's no finalized Sponsorship.") + return + + rows = [] + for sponsorship in qs.iterator(): + base_row = { + "sponsor": sponsorship.sponsor.name, + "description": sponsorship.sponsor.description, + "logo": sponsorship.sponsor.web_logo.url, + "sponsor_url": sponsorship.sponsor.landing_page_url, + "start_date": sponsorship.start_date.isoformat(), + "end_date": sponsorship.end_date.isoformat(), + } + + benefits = sponsorship.benefits.select_related('sponsorship_benefit') + for benefit in benefits.iterator(): + # TODO implement this as DB objects not hardcoded checks + # - check for logo placements + # - use the program to determine the publisher + # - use the flight to determine the placement (footer/sidebar/sponsor etc) + flight_mapping = { + # Foundation + "Logo on python.org": "sponsors", + "jobs.python.org support": "jobs", + "Logo listed on PSF blog": "blogspot", + # Pycon + "PyCon website Listing": "sponsors", + # Pypi + "Logo on the PyPI sponsors page": "sponsors", + "Logo in a prominent position on the PyPI project detail page": "sidebar", + "Logo on the PyPI footer": "footer", + # Core dev + "docs.python.org recognition": "docs", + "Logo on python.org/downloads/": "docs-download", + "Logo recognition on devguide.python.org/": "devguide" + } + + publisher = benefit.program.name + flight = flight_mapping.get(benefit.name) + if publisher and flight: + row = base_row.copy() + + if not sponsorship.sponsor.web_logo: + print(f"WARNING: sponsor {sponsorship.sponsor} without logo") + continue + + if publisher == "Foundation" and flight in ["jobs", "blogspot"]: + row["sponsor_url"] = "https://www.python.org/psf/sponsorship/sponsors/" + + row["publisher"] = publisher + row["flight"] = slugify(publisher) + '-' + flight + rows.append(row) + + columns = [ + "publisher", + "flight", + "sponsor", + "description", + "logo", + "start_date", + "end_date", + "sponsor_url", + ] + with open('sponsors.csv', 'w') as fd: + writer = csv.DictWriter(fd, fieldnames=columns) + writer.writeheader() + writer.writerows(rows) + + print(f"Done!") diff --git a/sponsors/managers.py b/sponsors/managers.py index bbc2a2e88..fdb9d9e1b 100644 --- a/sponsors/managers.py +++ b/sponsors/managers.py @@ -1,3 +1,5 @@ +from django.db.models import Count +from ordered_model.models import OrderedModelManager from django.db.models import Q, Subquery from django.db.models.query import QuerySet @@ -18,6 +20,9 @@ def visible_to(self, user): status__in=status, ).select_related('sponsor') + def finalized(self): + return self.filter(status=self.model.FINALIZED) + class SponsorContactQuerySet(QuerySet): def get_primary_contact(self, sponsor): @@ -25,3 +30,21 @@ def get_primary_contact(self, sponsor): if not contact: raise self.model.DoesNotExist() return contact + + +class SponsorshipBenefitManager(OrderedModelManager): + def with_conflicts(self): + return self.exclude(conflicts__isnull=True) + + def without_conflicts(self): + return self.filter(conflicts__isnull=True) + + def add_ons(self): + return self.annotate(num_packages=Count("packages")).filter(num_packages=0) + + def with_packages(self): + return ( + self.annotate(num_packages=Count("packages")) + .exclude(num_packages=0) + .order_by("-num_packages") + ) diff --git a/sponsors/migrations/0029_auto_20210715_2015.py b/sponsors/migrations/0029_auto_20210715_2015.py new file mode 100644 index 000000000..fa973ac97 --- /dev/null +++ b/sponsors/migrations/0029_auto_20210715_2015.py @@ -0,0 +1,48 @@ +# Generated by Django 2.0.13 on 2021-07-15 20:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('sponsors', '0028_auto_20210707_1426'), + ] + + operations = [ + migrations.CreateModel( + name='BenefitFeatureConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'verbose_name': 'Benefit Feature Configuration', + 'verbose_name_plural': 'Benefit Feature Configurations', + }, + ), + migrations.CreateModel( + name='LogoPlacementConfiguration', + 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')), + ('publisher', models.CharField(choices=[('psf', 'Foundation'), ('pycon', 'Pycon'), ('pypi', 'Pypi'), ('core', 'Core Dev')], help_text='On which site should the logo be displayed?', max_length=30, verbose_name='Publisher')), + ('logo_place', models.CharField(choices=[('sidebar', 'Sidebar'), ('sponsors', 'Sponsors Page'), ('jobs', 'Jobs'), ('blogpost', 'Blog'), ('footer', 'Footer'), ('docs', 'Docs'), ('download', 'Download Page'), ('devguide', 'Dev Guide')], help_text='Where the logo should be placed?', max_length=30, verbose_name='Logo Placement')), + ], + options={ + 'verbose_name': 'Logo Placement Configuration', + 'verbose_name_plural': 'Logo Placement Configurations', + }, + bases=('sponsors.benefitfeatureconfiguration',), + ), + migrations.AddField( + model_name='benefitfeatureconfiguration', + name='benefit', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sponsors.SponsorshipBenefit'), + ), + migrations.AddField( + model_name='benefitfeatureconfiguration', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_sponsors.benefitfeatureconfiguration_set+', to='contenttypes.ContentType'), + ), + ] diff --git a/sponsors/migrations/0030_auto_20210715_2023.py b/sponsors/migrations/0030_auto_20210715_2023.py new file mode 100644 index 000000000..ac2b5fc56 --- /dev/null +++ b/sponsors/migrations/0030_auto_20210715_2023.py @@ -0,0 +1,54 @@ +# Generated by Django 2.0.13 on 2021-07-15 20:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('sponsors', '0029_auto_20210715_2015'), + ] + + operations = [ + migrations.CreateModel( + name='BenefitFeature', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'verbose_name': 'Benefit Feature', + 'verbose_name_plural': 'Benefit Features', + }, + ), + migrations.AlterModelOptions( + name='logoplacementconfiguration', + options={'base_manager_name': 'objects', 'verbose_name': 'Logo Placement Configuration', 'verbose_name_plural': 'Logo Placement Configurations'}, + ), + migrations.CreateModel( + name='LogoPlacement', + 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')), + ('publisher', models.CharField(choices=[('psf', 'Foundation'), ('pycon', 'Pycon'), ('pypi', 'Pypi'), ('core', 'Core Dev')], help_text='On which site should the logo be displayed?', max_length=30, verbose_name='Publisher')), + ('logo_place', models.CharField(choices=[('sidebar', 'Sidebar'), ('sponsors', 'Sponsors Page'), ('jobs', 'Jobs'), ('blogpost', 'Blog'), ('footer', 'Footer'), ('docs', 'Docs'), ('download', 'Download Page'), ('devguide', 'Dev Guide')], help_text='Where the logo should be placed?', max_length=30, verbose_name='Logo Placement')), + ], + options={ + 'verbose_name': 'Logo Placement', + 'verbose_name_plural': 'Logo Placement', + 'abstract': False, + 'base_manager_name': 'objects', + }, + bases=('sponsors.benefitfeature', models.Model), + ), + migrations.AddField( + model_name='benefitfeature', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_sponsors.benefitfeature_set+', to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='benefitfeature', + name='sponsor_benefit', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sponsors.SponsorBenefit'), + ), + ] diff --git a/sponsors/models.py b/sponsors/models.py index 334898f33..0ef091ffa 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -1,22 +1,25 @@ +from abc import ABC from pathlib import Path from itertools import chain from num2words import num2words from django.conf import settings from django.core.files.storage import default_storage from django.core.exceptions import ObjectDoesNotExist -from django.db import models -from django.db.models import Sum, Count +from django.db import models, transaction +from django.db.models import Sum from django.template.defaultfilters import truncatechars from django.utils import timezone from django.utils.functional import cached_property from django.urls import reverse from markupfield.fields import MarkupField -from ordered_model.models import OrderedModel, OrderedModelManager +from ordered_model.models import OrderedModel from allauth.account.admin import EmailAddress from django_countries.fields import CountryField +from polymorphic.models import PolymorphicModel from cms.models import ContentManageable -from .managers import SponsorContactQuerySet, SponsorshipQuerySet +from .enums import LogoPlacementChoices, PublisherChoices +from .managers import SponsorContactQuerySet, SponsorshipQuerySet, SponsorshipBenefitManager from .exceptions import ( SponsorWithExistingApplicationException, InvalidStatusException, @@ -27,6 +30,9 @@ class SponsorshipPackage(OrderedModel): + """ + Represent default packages of benefits (visionary, sustainability etc) + """ name = models.CharField(max_length=64) sponsorship_amount = models.PositiveIntegerField() @@ -69,6 +75,9 @@ def has_user_customization(self, benefits): class SponsorshipProgram(OrderedModel): + """ + Possible programs that a benefit belongs to (Foundation, Pypi, etc) + """ name = models.CharField(max_length=64) description = models.TextField(null=True, blank=True) @@ -79,25 +88,11 @@ class Meta(OrderedModel.Meta): pass -class SponsorshipBenefitManager(OrderedModelManager): - def with_conflicts(self): - return self.exclude(conflicts__isnull=True) - - def without_conflicts(self): - return self.filter(conflicts__isnull=True) - - def add_ons(self): - return self.annotate(num_packages=Count("packages")).filter(num_packages=0) - - def with_packages(self): - return ( - self.annotate(num_packages=Count("packages")) - .exclude(num_packages=0) - .order_by("-num_packages") - ) - - class SponsorshipBenefit(OrderedModel): + """ + Benefit that sponsors can pick which are organized under + package and program. + """ objects = SponsorshipBenefitManager() # Public facing @@ -212,6 +207,10 @@ def remaining_capacity(self): # TODO implement logic to compute return self.capacity + @property + def features_config(self): + return self.benefitfeatureconfiguration_set + def __str__(self): return f"{self.program} > {self.name}" @@ -226,6 +225,10 @@ class Meta(OrderedModel.Meta): class SponsorContact(models.Model): + """ + Sponsor contact information + """ + objects = SponsorContactQuerySet.as_manager() sponsor = models.ForeignKey( @@ -259,6 +262,12 @@ def __str__(self): class Sponsorship(models.Model): + """ + Represente a sponsorship application by a sponsor. + It's responsible to group the set of selected benefits and + link it to sponsor + """ + APPLIED = "applied" REJECTED = "rejected" APPROVED = "approved" @@ -305,6 +314,7 @@ def __str__(self): return repr @classmethod + @transaction.atomic def new(cls, sponsor, benefits, package=None, submited_by=None): """ Creates a Sponsorship with a Sponsor and a list of SponsorshipBenefit. @@ -451,6 +461,10 @@ def next_status(self): class SponsorBenefit(OrderedModel): + """ + Link a benefit to a sponsorship application. + Created after a new sponsorship + """ sponsorship = models.ForeignKey( Sponsorship, on_delete=models.CASCADE, related_name="benefits" ) @@ -500,10 +514,13 @@ def __str__(self): return f"{self.program} > {self.name}" return f"{self.program_name} > {self.name}" + @property + def features(self): + return self.benefitfeature_set @classmethod def new_copy(cls, benefit, **kwargs): - return cls.objects.create( + sponsor_benefit = cls.objects.create( sponsorship_benefit=benefit, program_name=benefit.program.name, name=benefit.name, @@ -513,6 +530,13 @@ def new_copy(cls, benefit, **kwargs): **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) + feature.save() + + return sponsor_benefit + @property def legal_clauses(self): if self.sponsorship_benefit is not None: @@ -524,6 +548,10 @@ class Meta(OrderedModel.Meta): class Sponsor(ContentManageable): + """ + Group all of the sponsor information, logo and contacts + """ + name = models.CharField( max_length=100, verbose_name="Sponsor name", @@ -606,6 +634,10 @@ def primary_contact(self): class LegalClause(OrderedModel): + """ + Legal clauses applied to benefits + """ + internal_name = models.CharField( max_length=1024, verbose_name="Internal Name", @@ -629,6 +661,10 @@ class Meta(OrderedModel.Meta): class Contract(models.Model): + """ + Contract model to oficialize a Sponsorship + """ + DRAFT = "draft" OUTDATED = "outdated" AWAITING_SIGNATURE = "awaiting signature" @@ -806,6 +842,7 @@ def execute(self, commit=True): self.status = self.EXECUTED self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.finalized_on = timezone.now().date() if commit: self.sponsorship.save() self.save() @@ -819,3 +856,105 @@ def nullify(self, commit=True): if commit: self.sponsorship.save() self.save() + + +######################################## +##### Benefit features abstract classes +class BaseLogoPlacement(models.Model): + publisher = models.CharField( + max_length=30, + choices=[(c.value, c.name.replace("_", " ").title()) for c in PublisherChoices], + verbose_name="Publisher", + help_text="On which site should the logo be displayed?" + ) + logo_place = models.CharField( + max_length=30, + choices=[(c.value, c.name.replace("_", " ").title()) for c in LogoPlacementChoices], + verbose_name="Logo Placement", + help_text="Where the logo should be placed?" + ) + + class Meta: + abstract = True + + +###################################################### +##### SponsorshipBenefit features configuration models +class BenefitFeatureConfiguration(PolymorphicModel): + """ + Base class for sponsorship benefits configuration. + """ + benefit = models.ForeignKey(SponsorshipBenefit, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Benefit Feature Configuration" + verbose_name_plural = "Benefit Feature Configurations" + + @property + def benefit_feature_class(self): + """ + Return a subclass of BenefitFeature related to this configuration. + Every configuration subclass must implement this property + """ + raise NotImplementedError + + def get_benefit_feature(self, **kwargs): + """ + Returns an instance of a configured type of BenefitFeature + """ + # Get all fields from benefit feature configuration base model + base_fields = set(BenefitFeatureConfiguration._meta.get_fields()) + # Get only the fields from the abstract base feature model + benefit_fields = set(self._meta.get_fields()) - base_fields + # Configure the related benefit feature using values from the configuration + for field in benefit_fields: + # Skip the OneToOne rel from the base class to BenefitFeatureConfiguration base class + # since this field only exists in child models + if BenefitFeatureConfiguration is getattr(field, 'related_model', None): + continue + kwargs[field.name] = getattr(self, field.name) + BenefitFeatureClass = self.benefit_feature_class + return BenefitFeatureClass(**kwargs) + + +class LogoPlacementConfiguration(BaseLogoPlacement, BenefitFeatureConfiguration): + """ + Configuration to control how sponsor logo should be placed + """ + + class Meta(BaseLogoPlacement.Meta, BenefitFeatureConfiguration.Meta): + verbose_name = "Logo Placement Configuration" + verbose_name_plural = "Logo Placement Configurations" + + @property + def benefit_feature_class(self): + return LogoPlacement + + def __str__(self): + return f"Logo Configuration for {self.get_publisher_display()} at {self.get_logo_place_display()}" + + +#################################### +##### SponsorBenefit features models +class BenefitFeature(PolymorphicModel): + """ + Base class for sponsor benefits features. + """ + sponsor_benefit = models.ForeignKey(SponsorBenefit, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Benefit Feature" + verbose_name_plural = "Benefit Features" + + +class LogoPlacement(BaseLogoPlacement, BenefitFeature): + """ + Logo Placement feature for sponsor benefits + """ + + class Meta(BaseLogoPlacement.Meta, BenefitFeature.Meta): + verbose_name = "Logo Placement" + verbose_name_plural = "Logo Placement" + + def __str__(self): + return f"Logo for {self.get_publisher_display()} at {self.get_logo_place_display()}" diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 7967c2638..58583a5ce 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -13,13 +13,16 @@ SponsorContact, SponsorBenefit, LegalClause, - Contract + Contract, + LogoPlacementConfiguration, + LogoPlacement, ) from ..exceptions import ( SponsorWithExistingApplicationException, SponsorshipInvalidDateRangeException, InvalidStatusException, ) +from ..enums import PublisherChoices, LogoPlacementChoices class SponsorshipBenefitModelTests(TestCase): @@ -518,6 +521,7 @@ def test_execute_contract(self): self.assertEqual(contract.status, Contract.EXECUTED) self.assertEqual(contract.sponsorship.status, Sponsorship.FINALIZED) + self.assertEqual(contract.sponsorship.finalized_on, date.today()) def test_raise_invalid_status_when_trying_to_execute_contract_if_not_awaiting_signature(self): contract = baker.make_recipe( @@ -544,3 +548,43 @@ def test_raise_invalid_status_when_trying_to_nullify_contract_if_not_awaiting_si with self.assertRaises(InvalidStatusException): contract.nullify() + + +class LogoPlacementConfigurationModelTests(TestCase): + + def test_get_benefit_feature_respecting_configuration(self): + config = baker.make( + LogoPlacementConfiguration, + publisher=PublisherChoices.FOUNDATION, + logo_place=LogoPlacementChoices.FOOTER, + ) + + benefit_feature = config.get_benefit_feature() + + self.assertIsInstance(benefit_feature, LogoPlacement) + self.assertEqual(benefit_feature.publisher, PublisherChoices.FOUNDATION) + self.assertEqual(benefit_feature.logo_place, LogoPlacementChoices.FOOTER) + # can't save object without related sponsor benefit + self.assertIsNone(benefit_feature.pk) + self.assertIsNone(benefit_feature.sponsor_benefit_id) + + +class SponsorBenefitModelTests(TestCase): + + def setUp(self): + self.sponsorship = baker.make(Sponsorship) + self.sponsorship_benefit = baker.make(SponsorshipBenefit) + + def test_new_copy_also_add_benefit_feature_when_creating_sponsor_benefit(self): + benefit_config = baker.make(LogoPlacementConfiguration, benefit=self.sponsorship_benefit) + self.assertEqual(0, LogoPlacement.objects.count()) + + sponsor_benefit = SponsorBenefit.new_copy( + self.sponsorship_benefit, sponsorship=self.sponsorship + ) + + self.assertEqual(1, LogoPlacement.objects.count()) + benefit_feature = sponsor_benefit.features.get() + self.assertIsInstance(benefit_feature, LogoPlacement) + self.assertEqual(benefit_feature.publisher, benefit_config.publisher) + self.assertEqual(benefit_feature.logo_place, benefit_config.logo_place)