diff --git a/sponsors/admin.py b/sponsors/admin.py index 243d20d7f..aba5a2081 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -48,8 +48,8 @@ class SponsorshipProgramAdmin(OrderedModelAdmin): class MultiPartForceForm(ModelForm): - def is_multipart(self): - return True + def is_multipart(self): + return True class BenefitFeatureConfigurationInline(StackedPolymorphicInline): diff --git a/sponsors/forms.py b/sponsors/forms.py index ee845a0a7..e436fa9a3 100644 --- a/sponsors/forms.py +++ b/sponsors/forms.py @@ -1,3 +1,4 @@ +import datetime from itertools import chain from django import forms from django.conf import settings @@ -5,6 +6,7 @@ from django.db.models import Q from django.utils import timezone from django.utils.functional import cached_property +from django.utils.safestring import mark_safe from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django_countries.fields import CountryField @@ -614,11 +616,24 @@ def __init__(self, *args, **kwargs): self.required_assets = self.required_assets.filter(pk__in=required_assets_ids) fields = {} - for required_asset in self.required_assets: + ordered_assets = sorted( + self.required_assets, + key=lambda x: (-int(bool(x.value)), x.due_date if x.due_date else datetime.date.min), + reverse=True, + ) + + for required_asset in ordered_assets: value = required_asset.value f_name = self._get_field_name(required_asset) required = bool(value) - fields[f_name] = required_asset.as_form_field(required=required, initial=value) + field = required_asset.as_form_field(required=required, initial=value) + + if required_asset.due_date and not bool(value): + field.label = mark_safe(f"{field.label} (Required by {required_asset.due_date})") + if bool(value): + field.label = mark_safe(f"{field.label} (Fulfilled, thank you!)") + + fields[f_name] = field self.fields.update(fields) diff --git a/sponsors/management/commands/check_sponsorship_assets_due_date.py b/sponsors/management/commands/check_sponsorship_assets_due_date.py new file mode 100644 index 000000000..d81f212f8 --- /dev/null +++ b/sponsors/management/commands/check_sponsorship_assets_due_date.py @@ -0,0 +1,64 @@ +from datetime import timedelta + +from django.core.management import BaseCommand +from django.db.models import Subquery +from django.utils import timezone + +from sponsors.models import Sponsorship, Contract, BenefitFeature +from sponsors.notifications import AssetCloseToDueDateNotificationToSponsors + + +class Command(BaseCommand): + """ + This command will query for the sponsorships which have any required asset + with a due date expiring within the certain amount of days + """ + help = "Send notifications to sponsorship with pending required assets" + + def add_arguments(self, parser): + help = "Num of days to be used as interval up to target date" + parser.add_argument("num_days", nargs="?", default="7", help=help) + parser.add_argument("--no-input", action="store_true", help="Tells Django to NOT prompt the user for input of any kind.") + + def handle(self, **options): + num_days = options["num_days"] + ask_input = not options["no_input"] + target_date = (timezone.now() + timedelta(days=int(num_days))).date() + + req_assets = BenefitFeature.objects.required_assets() + + sponsorship_ids = Subquery(req_assets.values_list("sponsor_benefit__sponsorship_id").distinct()) + sponsorships = Sponsorship.objects.filter(id__in=sponsorship_ids) + + sponsorships_to_notify = [] + for sponsorship in sponsorships: + to_notify = any([ + asset.due_date == target_date + for asset in req_assets.from_sponsorship(sponsorship) + if asset.due_date + ]) + if to_notify: + sponsorships_to_notify.append(sponsorship) + + if not sponsorships_to_notify: + print("No sponsorship with required assets with due date close to expiration.") + return + + user_input = "" + while user_input != "Y" and ask_input: + msg = f"Contacts from {len(sponsorships_to_notify)} with pending assets with expiring due date will get " \ + f"notified. " + msg += "Do you want to proceed? [Y/n]: " + user_input = input(msg).strip().upper() + if user_input == "N": + print("Finishing execution.") + return + elif user_input != "Y": + print("Invalid option...") + + notification = AssetCloseToDueDateNotificationToSponsors() + for sponsorship in sponsorships_to_notify: + kwargs = {"sponsorship": sponsorship, "due_date": target_date} + notification.notify(**kwargs) + + print("Notifications sent!") diff --git a/sponsors/migrations/0071_auto_20220113_1843.py b/sponsors/migrations/0071_auto_20220113_1843.py new file mode 100644 index 000000000..7c66e5ba5 --- /dev/null +++ b/sponsors/migrations/0071_auto_20220113_1843.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.24 on 2022-01-13 18:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0070_auto_20220111_2055'), + ] + + operations = [ + migrations.AddField( + model_name='requiredimgasset', + name='due_date', + field=models.DateField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='requiredimgassetconfiguration', + name='due_date', + field=models.DateField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='requiredtextasset', + name='due_date', + field=models.DateField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='requiredtextassetconfiguration', + name='due_date', + field=models.DateField(blank=True, default=None, null=True), + ), + ] diff --git a/sponsors/migrations/0072_auto_20220125_2005.py b/sponsors/migrations/0072_auto_20220125_2005.py new file mode 100644 index 000000000..86247bc08 --- /dev/null +++ b/sponsors/migrations/0072_auto_20220125_2005.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.24 on 2022-01-25 20:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sponsors', '0071_auto_20220113_1843'), + ] + + operations = [ + migrations.AddField( + model_name='requiredtextasset', + name='max_length', + field=models.IntegerField(blank=True, default=None, help_text='Limit to length of the input, empty means unlimited', null=True), + ), + migrations.AddField( + model_name='requiredtextassetconfiguration', + name='max_length', + field=models.IntegerField(blank=True, default=None, help_text='Limit to length of the input, empty means unlimited', null=True), + ), + ] diff --git a/sponsors/models/benefits.py b/sponsors/models/benefits.py index c99b1e317..a6eadce6a 100644 --- a/sponsors/models/benefits.py +++ b/sponsors/models/benefits.py @@ -88,6 +88,8 @@ class Meta: class BaseRequiredAsset(BaseAsset): + due_date = models.DateField(default=None, null=True, blank=True) + class Meta: abstract = True @@ -161,6 +163,12 @@ class BaseRequiredTextAsset(BaseRequiredAsset): default="", blank=True ) + max_length = models.IntegerField( + default=None, + help_text="Limit to length of the input, empty means unlimited", + null=True, + blank=True, + ) class Meta(BaseRequiredAsset.Meta): abstract = True @@ -532,7 +540,11 @@ def as_form_field(self, **kwargs): help_text = kwargs.pop("help_text", self.help_text) label = kwargs.pop("label", self.label) required = kwargs.pop("required", False) - return forms.CharField(required=required, help_text=help_text, label=label, widget=forms.TextInput, **kwargs) + max_length = self.max_length + widget = forms.TextInput + if max_length is None or max_length > 256: + widget = forms.Textarea + return forms.CharField(required=required, help_text=help_text, label=label, widget=widget, **kwargs) class ProvidedTextAsset(ProvidedAssetMixin, BaseProvidedTextAsset, BenefitFeature): diff --git a/sponsors/models/sponsors.py b/sponsors/models/sponsors.py index 7475281a7..fad84d7dc 100644 --- a/sponsors/models/sponsors.py +++ b/sponsors/models/sponsors.py @@ -273,7 +273,6 @@ def reset_attributes(self, benefit): self.save() - def delete(self): self.features.all().delete() super().delete() diff --git a/sponsors/notifications.py b/sponsors/notifications.py index 66c980550..e2cccfff4 100644 --- a/sponsors/notifications.py +++ b/sponsors/notifications.py @@ -216,3 +216,17 @@ class RefreshSponsorshipsCache: def notify(self, *args, **kwargs): # clean up cached used by "sponsors/partials/sponsors-list.html" cache.delete("CACHED_SPONSORS_LIST") + + +class AssetCloseToDueDateNotificationToSponsors(BaseEmailSponsorshipNotification): + subject_template = "sponsors/email/sponsor_expiring_assets_subject.txt" + message_template = "sponsors/email/sponsor_expiring_assets.txt" + email_context_keys = ["sponsorship", "required_assets", "due_date"] + + def get_recipient_list(self, context): + return context["sponsorship"].verified_emails + + def get_email_context(self, **kwargs): + context = super().get_email_context(**kwargs) + context["required_assets"] = BenefitFeature.objects.from_sponsorship(context["sponsorship"]).required_assets() + return context diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 0822a9efa..507bc1b57 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -976,6 +976,15 @@ def test_build_form_field_from_input(self): text_asset = baker.make(RequiredTextAsset, _fill_optional=True) field = text_asset.as_form_field() self.assertIsInstance(field, forms.CharField) + self.assertIsInstance(field.widget, forms.Textarea) + self.assertFalse(field.required) + self.assertEqual(text_asset.help_text, field.help_text) + self.assertEqual(text_asset.label, field.label) + + def test_build_form_field_from_input_with_max_length(self): + text_asset = baker.make(RequiredTextAsset, _fill_optional=True, max_length=256) + field = text_asset.as_form_field() + self.assertIsInstance(field, forms.CharField) self.assertIsInstance(field.widget, forms.TextInput) self.assertFalse(field.required) self.assertEqual(text_asset.help_text, field.help_text) diff --git a/sponsors/tests/test_notifications.py b/sponsors/tests/test_notifications.py index 27edc2501..b3813eddf 100644 --- a/sponsors/tests/test_notifications.py +++ b/sponsors/tests/test_notifications.py @@ -1,3 +1,4 @@ +from datetime import date from unittest.mock import Mock from model_bakery import baker @@ -428,3 +429,60 @@ def test_create_log_entry(self): self.assertEqual(str(self.sponsorship), log_entry.object_repr) self.assertEqual(log_entry.action_flag, CHANGE) self.assertEqual(log_entry.change_message, "Notification 'Foo' was sent to contacts: administrative") + + +class AssetCloseToDueDateNotificationToSponsorsTestCase(TestCase): + def setUp(self): + self.notification = notifications.AssetCloseToDueDateNotificationToSponsors() + self.user = baker.make(settings.AUTH_USER_MODEL, email="foo@foo.com") + self.verified_email = baker.make(EmailAddress, verified=True) + self.unverified_email = baker.make(EmailAddress, verified=False) + self.sponsor_contacts = [ + baker.make( + "sponsors.SponsorContact", + email="foo@example.com", + primary=True, + sponsor__name="foo", + ), + baker.make("sponsors.SponsorContact", email=self.verified_email.email), + baker.make("sponsors.SponsorContact", email=self.unverified_email.email), + ] + self.sponsor = baker.make("sponsors.Sponsor", contacts=self.sponsor_contacts) + self.sponsorship = baker.make( + "sponsors.Sponsorship", sponsor=self.sponsor, submited_by=self.user + ) + self.subject_template = "sponsors/email/sponsor_expiring_assets_subject.txt" + self.content_template = "sponsors/email/sponsor_expiring_assets.txt" + + def test_send_email_using_correct_templates(self): + context = {"sponsorship": self.sponsorship} + expected_subject = render_to_string(self.subject_template, context).strip() + expected_content = render_to_string(self.content_template, context).strip() + + self.notification.notify(sponsorship=self.sponsorship) + self.assertTrue(mail.outbox) + + email = mail.outbox[0] + self.assertEqual(expected_subject, email.subject) + self.assertEqual(expected_content, email.body) + self.assertEqual(settings.SPONSORSHIP_NOTIFICATION_FROM_EMAIL, email.from_email) + self.assertCountEqual([self.user.email, self.verified_email.email], email.to) + + def test_send_email_to_correct_recipients(self): + context = {"user": self.user, "sponsorship": self.sponsorship} + expected_contacts = ["foo@foo.com", self.verified_email.email] + self.assertCountEqual( + expected_contacts, self.notification.get_recipient_list(context) + ) + + def test_list_required_assets_in_email_context(self): + cfg = baker.make(RequiredTextAssetConfiguration, internal_name='input') + benefit = baker.make(SponsorBenefit, sponsorship=self.sponsorship) + asset = cfg.create_benefit_feature(benefit) + base_context = {"sponsorship": self.sponsorship, "due_date": date.today()} + context = self.notification.get_email_context(**base_context) + self.assertEqual(3, len(context)) + self.assertEqual(self.sponsorship, context["sponsorship"]) + self.assertEqual(1, len(context["required_assets"])) + self.assertEqual(date.today(), context["due_date"]) + self.assertIn(asset, context["required_assets"]) diff --git a/templates/sponsors/email/sponsor_expiring_assets.txt b/templates/sponsors/email/sponsor_expiring_assets.txt new file mode 100644 index 000000000..4c86d903c --- /dev/null +++ b/templates/sponsors/email/sponsor_expiring_assets.txt @@ -0,0 +1,10 @@ +Hello {{ sponsorship.sponsor.name }} team!, + +Your sponsorship requires one or more assets by {{ due_date|date }} in order for us to fulfill your benefits: + +You can submit your assets via the following link: + +https://python.org{% url "users:sponsorship_application_detail" sponsorship.pk %} + +Thanks, +Python Software Foundation diff --git a/templates/sponsors/email/sponsor_expiring_assets_subject.txt b/templates/sponsors/email/sponsor_expiring_assets_subject.txt new file mode 100644 index 000000000..82d3a2c86 --- /dev/null +++ b/templates/sponsors/email/sponsor_expiring_assets_subject.txt @@ -0,0 +1 @@ +The PSF needs more information in order fulill some of your Sponsorhip benefits! diff --git a/templates/users/sponsorship_detail.html b/templates/users/sponsorship_detail.html index 6c9bceb13..447b4245c 100644 --- a/templates/users/sponsorship_detail.html +++ b/templates/users/sponsorship_detail.html @@ -93,14 +93,14 @@
You've selected benefits which requires extra assets (logos, slides etc).
+You've selected benefits which requires extra assets (logos, slides etc) in order to be fulfilled.