From 28c21a9ec0533d6cc2e7d4cdf2867510a156e8ac Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 9 Jul 2021 15:54:07 -0300 Subject: [PATCH 01/17] Move manager to managers.py --- sponsors/managers.py | 20 ++++++++++++++++++++ sponsors/models.py | 24 +++--------------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/sponsors/managers.py b/sponsors/managers.py index bbc2a2e88..0244f0f88 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 @@ -25,3 +27,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/models.py b/sponsors/models.py index 334898f33..50dcdf7eb 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -5,18 +5,18 @@ 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.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 cms.models import ContentManageable -from .managers import SponsorContactQuerySet, SponsorshipQuerySet +from .managers import SponsorContactQuerySet, SponsorshipQuerySet, SponsorshipBenefitManager from .exceptions import ( SponsorWithExistingApplicationException, InvalidStatusException, @@ -79,24 +79,6 @@ 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): objects = SponsorshipBenefitManager() From 0c390d4ffbbf309f4408cc362524234200e226ab Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 9 Jul 2021 15:54:19 -0300 Subject: [PATCH 02/17] Add docstrings to help with models understanding --- sponsors/models.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/sponsors/models.py b/sponsors/models.py index 50dcdf7eb..d1d870b14 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -27,6 +27,9 @@ class SponsorshipPackage(OrderedModel): + """ + Represent default packages of benefits (visionary, sustainability etc) + """ name = models.CharField(max_length=64) sponsorship_amount = models.PositiveIntegerField() @@ -69,6 +72,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) @@ -80,6 +86,10 @@ class Meta(OrderedModel.Meta): class SponsorshipBenefit(OrderedModel): + """ + Benefit that sponsors can pick which are organized under + package and program. + """ objects = SponsorshipBenefitManager() # Public facing @@ -208,6 +218,10 @@ class Meta(OrderedModel.Meta): class SponsorContact(models.Model): + """ + Sponsor contact information + """ + objects = SponsorContactQuerySet.as_manager() sponsor = models.ForeignKey( @@ -241,6 +255,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" @@ -433,6 +453,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" ) @@ -506,6 +530,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", @@ -588,6 +616,10 @@ def primary_contact(self): class LegalClause(OrderedModel): + """ + Legal clauses applied to benefits + """ + internal_name = models.CharField( max_length=1024, verbose_name="Internal Name", @@ -611,6 +643,10 @@ class Meta(OrderedModel.Meta): class Contract(models.Model): + """ + Contract model to oficialize a Sponsorship + """ + DRAFT = "draft" OUTDATED = "outdated" AWAITING_SIGNATURE = "awaiting signature" From cc9d919c2c4226844fc35c279d8f81c692b57463 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 9 Jul 2021 16:41:36 -0300 Subject: [PATCH 03/17] Draft of a command to generate the sponsors.csv --- .../management/commands/gen_sponsors_csv.py | 86 +++++++++++++++++++ sponsors/managers.py | 3 + 2 files changed, 89 insertions(+) create mode 100644 sponsors/management/commands/gen_sponsors_csv.py diff --git a/sponsors/management/commands/gen_sponsors_csv.py b/sponsors/management/commands/gen_sponsors_csv.py new file mode 100644 index 000000000..a5a672d66 --- /dev/null +++ b/sponsors/management/commands/gen_sponsors_csv.py @@ -0,0 +1,86 @@ +import csv +from django.core.management import BaseCommand + +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 + + #Each site can be a publishers; + #Flight can be the sponsorship level with all the sponsors; + + rows = [] + for sponsorship in qs.iterator(): + base_row = { + "sponsor": sponsorship.sponsor.name, + "sponsor": 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", # TODO: QUESTION: both jobs and blogspot landing url shold point to python.org/sponsors right? + # Pycon + "PyCon website Listing": "sponsor", + "Virtual Booth": "virtual-booth", + "Session track naming rights": "track", # TODO: we shouldn't need these at the server, right? + # 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", # TODO: should we use the print logo here instead of the colored one? + # 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() + row["publisher"] = publisher + row["flight"] = flight + rows.append(row) + + + columns = [ + "publisher", + "flight", + "sponsor", + "description", + "logo", + "start_date", + "end_date", + "sponsor_url", + ] + with open('output.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 0244f0f88..fdb9d9e1b 100644 --- a/sponsors/managers.py +++ b/sponsors/managers.py @@ -20,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): From e43e62529f4649cbed9d1cf8a998526766cbb60b Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 13 Jul 2021 11:27:42 -0300 Subject: [PATCH 04/17] Add django-polymorphic as a dependency --- pydotorg/settings/base.py | 1 + requirements.txt | 1 + 2 files changed, 2 insertions(+) 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/requirements.txt b/requirements.txt index 1b66eee00..b131da853 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -r base-requirements.txt -r prod-requirements.txt +django-polymorphic==3.0.0 From a0851b1a1856c961b7bea23d3caf97659b586b55 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 13 Jul 2021 11:28:16 -0300 Subject: [PATCH 05/17] Introduce polymorphic models --- sponsors/enums.py | 18 ++++++++ .../migrations/0029_auto_20210713_1418.py | 46 +++++++++++++++++++ sponsors/models.py | 32 +++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 sponsors/enums.py create mode 100644 sponsors/migrations/0029_auto_20210713_1418.py 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/migrations/0029_auto_20210713_1418.py b/sponsors/migrations/0029_auto_20210713_1418.py new file mode 100644 index 000000000..d97333d35 --- /dev/null +++ b/sponsors/migrations/0029_auto_20210713_1418.py @@ -0,0 +1,46 @@ +# Generated by Django 2.0.13 on 2021-07-13 14:18 + +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='BenefitFeature', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'verbose_name': 'Benefit Features', + }, + ), + migrations.CreateModel( + name='SponsorLogoPlacement', + 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': 'Sponsor Logo Placements', + }, + bases=('sponsors.benefitfeature',), + ), + migrations.AddField( + model_name='benefitfeature', + name='benefit', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sponsors.SponsorshipBenefit'), + ), + 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'), + ), + ] diff --git a/sponsors/models.py b/sponsors/models.py index d1d870b14..d7ff19e6a 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -14,8 +14,10 @@ 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 .enums import LogoPlacementChoices, PublisherChoices from .managers import SponsorContactQuerySet, SponsorshipQuerySet, SponsorshipBenefitManager from .exceptions import ( SponsorWithExistingApplicationException, @@ -837,3 +839,33 @@ def nullify(self, commit=True): if commit: self.sponsorship.save() self.save() + + +class BenefitFeature(PolymorphicModel): + benefit = models.ForeignKey(SponsorshipBenefit, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Benefit Feature" + verbose_name_plural = "Benefit Features" + + +class SponsorLogoPlacement(BenefitFeature): + 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: + verbose_name = "Sponsor Logo Placement" + verbose_name_plural = "Sponsor Logo Placements" + + def __str__(self): + return f"Logo for {self.get_publisher_display()} at {self.get_logo_place_display()}" From 1c204b9169d7bd31d85f2073b065ac7e22fefd9a Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 13 Jul 2021 11:29:10 -0300 Subject: [PATCH 06/17] Display benefit feature as stacked inline at benefit admin page --- sponsors/admin.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index 62a7324bc..f39ed9596 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, + BenefitFeature, + SponsorLogoPlacement, ) from sponsors import views_admin from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm @@ -30,8 +33,19 @@ class SponsorshipProgramAdmin(OrderedModelAdmin): ] +class BenefitFeatureInline(StackedPolymorphicInline): + class SponsorLogoPlacementInline(StackedPolymorphicInline.Child): + model = SponsorLogoPlacement + + model = BenefitFeature + child_inlines = [ + SponsorLogoPlacementInline + ] + + @admin.register(SponsorshipBenefit) -class SponsorshipBenefitAdmin(OrderedModelAdmin): +class SponsorshipBenefitAdmin(PolymorphicInlineSupportMixin, OrderedModelAdmin): + inlines = [BenefitFeatureInline] ordering = ("program", "order") list_display = [ "program", From 6e5e203d69c57be14b70ee3aad79af0278409731 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 13 Jul 2021 11:29:53 -0300 Subject: [PATCH 07/17] Remove logo placements that aren't necessary to be tracked --- sponsors/management/commands/gen_sponsors_csv.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sponsors/management/commands/gen_sponsors_csv.py b/sponsors/management/commands/gen_sponsors_csv.py index a5a672d66..f95843d14 100644 --- a/sponsors/management/commands/gen_sponsors_csv.py +++ b/sponsors/management/commands/gen_sponsors_csv.py @@ -46,9 +46,7 @@ def handle(self, **options): "jobs.python.org support": "jobs", # "Logo listed on PSF blog": "blogspot", # TODO: QUESTION: both jobs and blogspot landing url shold point to python.org/sponsors right? # Pycon - "PyCon website Listing": "sponsor", - "Virtual Booth": "virtual-booth", - "Session track naming rights": "track", # TODO: we shouldn't need these at the server, right? + "PyCon website Listing": "sponsors", # Pypi "Logo on the PyPI sponsors page": "sponsors", "Logo in a prominent position on the PyPI project detail page": "sidebar", From d7b2296e80ae0840e4baff82d4857fcdfdad6def Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 14 Jul 2021 13:26:58 -0300 Subject: [PATCH 08/17] Fixes for an initial draft on the command to output the CSV --- .../management/commands/gen_sponsors_csv.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/sponsors/management/commands/gen_sponsors_csv.py b/sponsors/management/commands/gen_sponsors_csv.py index f95843d14..84249fcaa 100644 --- a/sponsors/management/commands/gen_sponsors_csv.py +++ b/sponsors/management/commands/gen_sponsors_csv.py @@ -1,5 +1,6 @@ import csv from django.core.management import BaseCommand +from django.template.defaultfilters import slugify from sponsors.models import Sponsorship, Contract @@ -20,14 +21,11 @@ def handle(self, **options): print("There's no finalized Sponsorship.") return - #Each site can be a publishers; - #Flight can be the sponsorship level with all the sponsors; - rows = [] for sponsorship in qs.iterator(): base_row = { "sponsor": sponsorship.sponsor.name, - "sponsor": sponsorship.sponsor.description, + "description": sponsorship.sponsor.description, "logo": sponsorship.sponsor.web_logo.url, "sponsor_url": sponsorship.sponsor.landing_page_url, "start_date": sponsorship.start_date.isoformat(), @@ -42,15 +40,15 @@ def handle(self, **options): # - use the flight to determine the placement (footer/sidebar/sponsor etc) flight_mapping = { # Foundation - "Logo on python.org": "sponsors", + "Logo on python.org": "sponsors", # redirect to sponsor landing url "jobs.python.org support": "jobs", # - "Logo listed on PSF blog": "blogspot", # TODO: QUESTION: both jobs and blogspot landing url shold point to python.org/sponsors right? + "Logo listed on PSF blog": "blogspot", # TODO: QUESTION: both jobs and blogspot landing url shold point to python.org/sponsors right? yes # 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", # TODO: should we use the print logo here instead of the colored one? + "Logo on the PyPI footer": "footer", # Core dev "docs.python.org recognition": "docs", "Logo on python.org/downloads/": "docs-download", @@ -61,11 +59,18 @@ def handle(self, **options): 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"] = flight + row["flight"] = slugify(publisher) + '-' + flight rows.append(row) - columns = [ "publisher", "flight", @@ -76,7 +81,7 @@ def handle(self, **options): "end_date", "sponsor_url", ] - with open('output.csv', 'w') as fd: + with open('sponsors.csv', 'w') as fd: writer = csv.DictWriter(fd, fieldnames=columns) writer.writeheader() writer.writerows(rows) From 046cadc44a6a0075a2330823391d075f5668049d Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 15 Jul 2021 17:15:46 -0300 Subject: [PATCH 09/17] Sponsorship benefit related models should be use to configure the features --- sponsors/admin.py | 16 +++++++------- ...713_1418.py => 0029_auto_20210715_2015.py} | 22 ++++++++++--------- sponsors/models.py | 21 +++++++++++++----- 3 files changed, 35 insertions(+), 24 deletions(-) rename sponsors/migrations/{0029_auto_20210713_1418.py => 0029_auto_20210715_2015.py} (63%) diff --git a/sponsors/admin.py b/sponsors/admin.py index f39ed9596..2863847a3 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -16,8 +16,8 @@ SponsorBenefit, LegalClause, Contract, - BenefitFeature, - SponsorLogoPlacement, + BenefitFeatureConfiguration, + LogoPlacementConfiguration, ) from sponsors import views_admin from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm @@ -33,19 +33,19 @@ class SponsorshipProgramAdmin(OrderedModelAdmin): ] -class BenefitFeatureInline(StackedPolymorphicInline): - class SponsorLogoPlacementInline(StackedPolymorphicInline.Child): - model = SponsorLogoPlacement +class BenefitFeatureConfigurationInline(StackedPolymorphicInline): + class LogoPlacementConfigurationInline(StackedPolymorphicInline.Child): + model = LogoPlacementConfiguration - model = BenefitFeature + model = BenefitFeatureConfiguration child_inlines = [ - SponsorLogoPlacementInline + LogoPlacementConfigurationInline ] @admin.register(SponsorshipBenefit) class SponsorshipBenefitAdmin(PolymorphicInlineSupportMixin, OrderedModelAdmin): - inlines = [BenefitFeatureInline] + inlines = [BenefitFeatureConfigurationInline] ordering = ("program", "order") list_display = [ "program", diff --git a/sponsors/migrations/0029_auto_20210713_1418.py b/sponsors/migrations/0029_auto_20210715_2015.py similarity index 63% rename from sponsors/migrations/0029_auto_20210713_1418.py rename to sponsors/migrations/0029_auto_20210715_2015.py index d97333d35..fa973ac97 100644 --- a/sponsors/migrations/0029_auto_20210713_1418.py +++ b/sponsors/migrations/0029_auto_20210715_2015.py @@ -1,4 +1,4 @@ -# Generated by Django 2.0.13 on 2021-07-13 14:18 +# Generated by Django 2.0.13 on 2021-07-15 20:15 from django.db import migrations, models import django.db.models.deletion @@ -13,34 +13,36 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='BenefitFeature', + name='BenefitFeatureConfiguration', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], options={ - 'verbose_name': 'Benefit Features', + 'verbose_name': 'Benefit Feature Configuration', + 'verbose_name_plural': 'Benefit Feature Configurations', }, ), migrations.CreateModel( - name='SponsorLogoPlacement', + name='LogoPlacementConfiguration', 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')), + ('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': 'Sponsor Logo Placements', + 'verbose_name': 'Logo Placement Configuration', + 'verbose_name_plural': 'Logo Placement Configurations', }, - bases=('sponsors.benefitfeature',), + bases=('sponsors.benefitfeatureconfiguration',), ), migrations.AddField( - model_name='benefitfeature', + model_name='benefitfeatureconfiguration', name='benefit', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sponsors.SponsorshipBenefit'), ), migrations.AddField( - model_name='benefitfeature', + model_name='benefitfeatureconfiguration', 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'), + 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/models.py b/sponsors/models.py index d7ff19e6a..990ca48a2 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -841,15 +841,24 @@ def nullify(self, commit=True): self.save() -class BenefitFeature(PolymorphicModel): +##### 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" - verbose_name_plural = "Benefit Features" + verbose_name = "Benefit Feature Configuration" + verbose_name_plural = "Benefit Feature Configurations" -class SponsorLogoPlacement(BenefitFeature): +class LogoPlacementConfiguration(BenefitFeatureConfiguration): + """ + Configuration to control how sponsor logo should be placed + """ publisher = models.CharField( max_length=30, choices=[(c.value, c.name.replace("_", " ").title()) for c in PublisherChoices], @@ -864,8 +873,8 @@ class SponsorLogoPlacement(BenefitFeature): ) class Meta: - verbose_name = "Sponsor Logo Placement" - verbose_name_plural = "Sponsor Logo Placements" + verbose_name = "Logo Placement Configuration" + verbose_name_plural = "Logo Placement Configurations" def __str__(self): return f"Logo for {self.get_publisher_display()} at {self.get_logo_place_display()}" From 8f8ac5b2f1e7a5d1200b824f2e2a71dc2f72e0d8 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 15 Jul 2021 17:24:29 -0300 Subject: [PATCH 10/17] Create benefit feature models --- .../migrations/0030_auto_20210715_2023.py | 54 ++++++++++++++++ sponsors/models.py | 63 ++++++++++++++----- 2 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 sponsors/migrations/0030_auto_20210715_2023.py 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 990ca48a2..3f7b948f5 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -841,6 +841,27 @@ def nullify(self, commit=True): 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): @@ -855,26 +876,40 @@ class Meta: verbose_name_plural = "Benefit Feature Configurations" -class LogoPlacementConfiguration(BenefitFeatureConfiguration): +class LogoPlacementConfiguration(BaseLogoPlacement, BenefitFeatureConfiguration): """ Configuration to control how sponsor logo should be placed """ - 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: + class Meta(BaseLogoPlacement.Meta, BenefitFeatureConfiguration.Meta): verbose_name = "Logo Placement Configuration" verbose_name_plural = "Logo Placement Configurations" + 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()}" From b0b63d126f49ae7cddf0450bb377c09e8933fbbf Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 15 Jul 2021 17:57:09 -0300 Subject: [PATCH 11/17] Create base method to generate a valid BenefitFeature from a BenefitFeatureConfiguration --- sponsors/models.py | 32 +++++++++++++++++++++++++++++++- sponsors/tests/test_models.py | 24 +++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/sponsors/models.py b/sponsors/models.py index 3f7b948f5..67c4adf26 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -868,13 +868,39 @@ 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): + """ + 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 + kwargs = {} + 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): """ @@ -885,6 +911,10 @@ 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()}" diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 7967c2638..14cf9509a 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): @@ -544,3 +547,22 @@ 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) From facfba5f8f1ff11ff9150e037b36170c08a91c5c Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 15 Jul 2021 18:17:36 -0300 Subject: [PATCH 12/17] Add benefit features to SponsorBenefit at creation time --- sponsors/models.py | 27 ++++++++++++++++++++------- sponsors/tests/test_models.py | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/sponsors/models.py b/sponsors/models.py index 67c4adf26..9154a02b2 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -1,3 +1,4 @@ +from abc import ABC from pathlib import Path from itertools import chain from num2words import num2words @@ -206,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}" @@ -508,10 +513,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, @@ -521,6 +529,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: @@ -841,9 +856,8 @@ def nullify(self, commit=True): self.save() +######################################## ##### Benefit features abstract classes - - class BaseLogoPlacement(models.Model): publisher = models.CharField( max_length=30, @@ -862,8 +876,8 @@ class Meta: abstract = True +###################################################### ##### SponsorshipBenefit features configuration models - class BenefitFeatureConfiguration(PolymorphicModel): """ Base class for sponsorship benefits configuration. @@ -882,7 +896,7 @@ def benefit_feature_class(self): """ raise NotImplementedError - def get_benefit_feature(self): + def get_benefit_feature(self, **kwargs): """ Returns an instance of a configured type of BenefitFeature """ @@ -891,7 +905,6 @@ def get_benefit_feature(self): # 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 - kwargs = {} 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 @@ -919,8 +932,8 @@ 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. diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 14cf9509a..5bfc9f6aa 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -566,3 +566,24 @@ def test_get_benefit_feature_respecting_configuration(self): # 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) From 02e242736e828f254af9831d714f4f8322adb0ae Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 15 Jul 2021 18:19:13 -0300 Subject: [PATCH 13/17] Method to create a new sponsors and, thus, sponsor benefits/features run within a transaction --- sponsors/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sponsors/models.py b/sponsors/models.py index 9154a02b2..df7047eff 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -5,7 +5,7 @@ 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 import models, transaction from django.db.models import Sum from django.template.defaultfilters import truncatechars from django.utils import timezone @@ -314,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. From 8c591f9667b8166fa205ac7b2d131fe89780b6a7 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 15 Jul 2021 18:22:58 -0300 Subject: [PATCH 14/17] Move polymorphic dependency to correct settings --- base-requirements.txt | 1 + requirements.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/base-requirements.txt b/base-requirements.txt index f0bd6c1b6..46b343b90 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==3.0.0 diff --git a/requirements.txt b/requirements.txt index b131da853..1b66eee00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ -r base-requirements.txt -r prod-requirements.txt -django-polymorphic==3.0.0 From e87712715bd20b5a3d73acc1c821ead68ff51a21 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 15 Jul 2021 18:23:04 -0300 Subject: [PATCH 15/17] Remove old TODO comment --- sponsors/management/commands/gen_sponsors_csv.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sponsors/management/commands/gen_sponsors_csv.py b/sponsors/management/commands/gen_sponsors_csv.py index 84249fcaa..48e19f34d 100644 --- a/sponsors/management/commands/gen_sponsors_csv.py +++ b/sponsors/management/commands/gen_sponsors_csv.py @@ -40,9 +40,9 @@ def handle(self, **options): # - use the flight to determine the placement (footer/sidebar/sponsor etc) flight_mapping = { # Foundation - "Logo on python.org": "sponsors", # redirect to sponsor landing url - "jobs.python.org support": "jobs", # - "Logo listed on PSF blog": "blogspot", # TODO: QUESTION: both jobs and blogspot landing url shold point to python.org/sponsors right? yes + "Logo on python.org": "sponsors", + "jobs.python.org support": "jobs", + "Logo listed on PSF blog": "blogspot", # Pycon "PyCon website Listing": "sponsors", # Pypi From 1afafbcf9daa041c62d5a45bedf64171225929be Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 21 Jul 2021 15:16:44 -0300 Subject: [PATCH 16/17] Use previous versions from django-polymorphic --- base-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base-requirements.txt b/base-requirements.txt index 46b343b90..3e0055de9 100644 --- a/base-requirements.txt +++ b/base-requirements.txt @@ -43,4 +43,4 @@ django-countries==6.1.3 xhtml2pdf==0.2.5 django-easy-pdf==0.1.1 num2words==0.5.10 -django-polymorphic==3.0.0 +django-polymorphic==2.1.2 From 7519428db6e97da7b11f5ef7dd6429e8d186cd73 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 21 Jul 2021 16:08:26 -0300 Subject: [PATCH 17/17] Add missing date update when sponsorship is finalized --- sponsors/models.py | 1 + sponsors/tests/test_models.py | 1 + 2 files changed, 2 insertions(+) diff --git a/sponsors/models.py b/sponsors/models.py index df7047eff..0ef091ffa 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -842,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() diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 5bfc9f6aa..58583a5ce 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -521,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(