Skip to content

Commit c5c1d04

Browse files
berinhardewdurbin
andauthored
Add optional due date to required assets (#1962)
* Make sure delete operations work as expected for Polymorphic models While testing the PR, I discovered this bug because I couldn't delete applications I've created. After doing some research, I figured out this is due to a known bug on django-polymorphic. More on this issue can be found in this issue: jazzband/django-polymorphic#229 * Fix identation * Add new due date column to required assets * Base command to notify sponsorship applications which have expiring required assets * Create new notification about required assets close to due date * Dispatch notifications via management command * Management command should look for future expiration dates * Add test to ensure due date within the email context * Disable asset input if past due date * Revert "Disable asset input if past due date" It's OK to allow them to submit after the deadline, as it is mostly for notifying them. If uploads after the deadline are acceptable, the sponsorship team will collect anything put into the boxes * cast "target_date" to a datetime.date so comparison works * update styling/wording on forms and emails * add max_length to RequiredTextAssets, render form to suit Co-authored-by: Ee Durbin <[email protected]>
1 parent 187791b commit c5c1d04

13 files changed

+247
-9
lines changed

sponsors/admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ class SponsorshipProgramAdmin(OrderedModelAdmin):
4848

4949

5050
class MultiPartForceForm(ModelForm):
51-
def is_multipart(self):
52-
return True
51+
def is_multipart(self):
52+
return True
5353

5454

5555
class BenefitFeatureConfigurationInline(StackedPolymorphicInline):

sponsors/forms.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import datetime
12
from itertools import chain
23
from django import forms
34
from django.conf import settings
45
from django.contrib.admin.widgets import AdminDateWidget
56
from django.db.models import Q
67
from django.utils import timezone
78
from django.utils.functional import cached_property
9+
from django.utils.safestring import mark_safe
810
from django.utils.text import slugify
911
from django.utils.translation import gettext_lazy as _
1012
from django_countries.fields import CountryField
@@ -614,11 +616,24 @@ def __init__(self, *args, **kwargs):
614616
self.required_assets = self.required_assets.filter(pk__in=required_assets_ids)
615617

616618
fields = {}
617-
for required_asset in self.required_assets:
619+
ordered_assets = sorted(
620+
self.required_assets,
621+
key=lambda x: (-int(bool(x.value)), x.due_date if x.due_date else datetime.date.min),
622+
reverse=True,
623+
)
624+
625+
for required_asset in ordered_assets:
618626
value = required_asset.value
619627
f_name = self._get_field_name(required_asset)
620628
required = bool(value)
621-
fields[f_name] = required_asset.as_form_field(required=required, initial=value)
629+
field = required_asset.as_form_field(required=required, initial=value)
630+
631+
if required_asset.due_date and not bool(value):
632+
field.label = mark_safe(f"{field.label} <b>(Required by {required_asset.due_date})</b>")
633+
if bool(value):
634+
field.label = mark_safe(f"{field.label} <small>(Fulfilled, thank you!)</small>")
635+
636+
fields[f_name] = field
622637

623638
self.fields.update(fields)
624639

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from datetime import timedelta
2+
3+
from django.core.management import BaseCommand
4+
from django.db.models import Subquery
5+
from django.utils import timezone
6+
7+
from sponsors.models import Sponsorship, Contract, BenefitFeature
8+
from sponsors.notifications import AssetCloseToDueDateNotificationToSponsors
9+
10+
11+
class Command(BaseCommand):
12+
"""
13+
This command will query for the sponsorships which have any required asset
14+
with a due date expiring within the certain amount of days
15+
"""
16+
help = "Send notifications to sponsorship with pending required assets"
17+
18+
def add_arguments(self, parser):
19+
help = "Num of days to be used as interval up to target date"
20+
parser.add_argument("num_days", nargs="?", default="7", help=help)
21+
parser.add_argument("--no-input", action="store_true", help="Tells Django to NOT prompt the user for input of any kind.")
22+
23+
def handle(self, **options):
24+
num_days = options["num_days"]
25+
ask_input = not options["no_input"]
26+
target_date = (timezone.now() + timedelta(days=int(num_days))).date()
27+
28+
req_assets = BenefitFeature.objects.required_assets()
29+
30+
sponsorship_ids = Subquery(req_assets.values_list("sponsor_benefit__sponsorship_id").distinct())
31+
sponsorships = Sponsorship.objects.filter(id__in=sponsorship_ids)
32+
33+
sponsorships_to_notify = []
34+
for sponsorship in sponsorships:
35+
to_notify = any([
36+
asset.due_date == target_date
37+
for asset in req_assets.from_sponsorship(sponsorship)
38+
if asset.due_date
39+
])
40+
if to_notify:
41+
sponsorships_to_notify.append(sponsorship)
42+
43+
if not sponsorships_to_notify:
44+
print("No sponsorship with required assets with due date close to expiration.")
45+
return
46+
47+
user_input = ""
48+
while user_input != "Y" and ask_input:
49+
msg = f"Contacts from {len(sponsorships_to_notify)} with pending assets with expiring due date will get " \
50+
f"notified. "
51+
msg += "Do you want to proceed? [Y/n]: "
52+
user_input = input(msg).strip().upper()
53+
if user_input == "N":
54+
print("Finishing execution.")
55+
return
56+
elif user_input != "Y":
57+
print("Invalid option...")
58+
59+
notification = AssetCloseToDueDateNotificationToSponsors()
60+
for sponsorship in sponsorships_to_notify:
61+
kwargs = {"sponsorship": sponsorship, "due_date": target_date}
62+
notification.notify(**kwargs)
63+
64+
print("Notifications sent!")
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 2.2.24 on 2022-01-13 18:43
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('sponsors', '0070_auto_20220111_2055'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='requiredimgasset',
15+
name='due_date',
16+
field=models.DateField(blank=True, default=None, null=True),
17+
),
18+
migrations.AddField(
19+
model_name='requiredimgassetconfiguration',
20+
name='due_date',
21+
field=models.DateField(blank=True, default=None, null=True),
22+
),
23+
migrations.AddField(
24+
model_name='requiredtextasset',
25+
name='due_date',
26+
field=models.DateField(blank=True, default=None, null=True),
27+
),
28+
migrations.AddField(
29+
model_name='requiredtextassetconfiguration',
30+
name='due_date',
31+
field=models.DateField(blank=True, default=None, null=True),
32+
),
33+
]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 2.2.24 on 2022-01-25 20:05
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('sponsors', '0071_auto_20220113_1843'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='requiredtextasset',
15+
name='max_length',
16+
field=models.IntegerField(blank=True, default=None, help_text='Limit to length of the input, empty means unlimited', null=True),
17+
),
18+
migrations.AddField(
19+
model_name='requiredtextassetconfiguration',
20+
name='max_length',
21+
field=models.IntegerField(blank=True, default=None, help_text='Limit to length of the input, empty means unlimited', null=True),
22+
),
23+
]

sponsors/models/benefits.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ class Meta:
8888

8989

9090
class BaseRequiredAsset(BaseAsset):
91+
due_date = models.DateField(default=None, null=True, blank=True)
92+
9193
class Meta:
9294
abstract = True
9395

@@ -161,6 +163,12 @@ class BaseRequiredTextAsset(BaseRequiredAsset):
161163
default="",
162164
blank=True
163165
)
166+
max_length = models.IntegerField(
167+
default=None,
168+
help_text="Limit to length of the input, empty means unlimited",
169+
null=True,
170+
blank=True,
171+
)
164172

165173
class Meta(BaseRequiredAsset.Meta):
166174
abstract = True
@@ -532,7 +540,11 @@ def as_form_field(self, **kwargs):
532540
help_text = kwargs.pop("help_text", self.help_text)
533541
label = kwargs.pop("label", self.label)
534542
required = kwargs.pop("required", False)
535-
return forms.CharField(required=required, help_text=help_text, label=label, widget=forms.TextInput, **kwargs)
543+
max_length = self.max_length
544+
widget = forms.TextInput
545+
if max_length is None or max_length > 256:
546+
widget = forms.Textarea
547+
return forms.CharField(required=required, help_text=help_text, label=label, widget=widget, **kwargs)
536548

537549

538550
class ProvidedTextAsset(ProvidedAssetMixin, BaseProvidedTextAsset, BenefitFeature):

sponsors/models/sponsors.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,6 @@ def reset_attributes(self, benefit):
273273

274274
self.save()
275275

276-
277276
def delete(self):
278277
self.features.all().delete()
279278
super().delete()

sponsors/notifications.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,17 @@ class RefreshSponsorshipsCache:
216216
def notify(self, *args, **kwargs):
217217
# clean up cached used by "sponsors/partials/sponsors-list.html"
218218
cache.delete("CACHED_SPONSORS_LIST")
219+
220+
221+
class AssetCloseToDueDateNotificationToSponsors(BaseEmailSponsorshipNotification):
222+
subject_template = "sponsors/email/sponsor_expiring_assets_subject.txt"
223+
message_template = "sponsors/email/sponsor_expiring_assets.txt"
224+
email_context_keys = ["sponsorship", "required_assets", "due_date"]
225+
226+
def get_recipient_list(self, context):
227+
return context["sponsorship"].verified_emails
228+
229+
def get_email_context(self, **kwargs):
230+
context = super().get_email_context(**kwargs)
231+
context["required_assets"] = BenefitFeature.objects.from_sponsorship(context["sponsorship"]).required_assets()
232+
return context

sponsors/tests/test_models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,15 @@ def test_build_form_field_from_input(self):
976976
text_asset = baker.make(RequiredTextAsset, _fill_optional=True)
977977
field = text_asset.as_form_field()
978978
self.assertIsInstance(field, forms.CharField)
979+
self.assertIsInstance(field.widget, forms.Textarea)
980+
self.assertFalse(field.required)
981+
self.assertEqual(text_asset.help_text, field.help_text)
982+
self.assertEqual(text_asset.label, field.label)
983+
984+
def test_build_form_field_from_input_with_max_length(self):
985+
text_asset = baker.make(RequiredTextAsset, _fill_optional=True, max_length=256)
986+
field = text_asset.as_form_field()
987+
self.assertIsInstance(field, forms.CharField)
979988
self.assertIsInstance(field.widget, forms.TextInput)
980989
self.assertFalse(field.required)
981990
self.assertEqual(text_asset.help_text, field.help_text)

sponsors/tests/test_notifications.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import date
12
from unittest.mock import Mock
23

34
from model_bakery import baker
@@ -428,3 +429,60 @@ def test_create_log_entry(self):
428429
self.assertEqual(str(self.sponsorship), log_entry.object_repr)
429430
self.assertEqual(log_entry.action_flag, CHANGE)
430431
self.assertEqual(log_entry.change_message, "Notification 'Foo' was sent to contacts: administrative")
432+
433+
434+
class AssetCloseToDueDateNotificationToSponsorsTestCase(TestCase):
435+
def setUp(self):
436+
self.notification = notifications.AssetCloseToDueDateNotificationToSponsors()
437+
self.user = baker.make(settings.AUTH_USER_MODEL, email="[email protected]")
438+
self.verified_email = baker.make(EmailAddress, verified=True)
439+
self.unverified_email = baker.make(EmailAddress, verified=False)
440+
self.sponsor_contacts = [
441+
baker.make(
442+
"sponsors.SponsorContact",
443+
444+
primary=True,
445+
sponsor__name="foo",
446+
),
447+
baker.make("sponsors.SponsorContact", email=self.verified_email.email),
448+
baker.make("sponsors.SponsorContact", email=self.unverified_email.email),
449+
]
450+
self.sponsor = baker.make("sponsors.Sponsor", contacts=self.sponsor_contacts)
451+
self.sponsorship = baker.make(
452+
"sponsors.Sponsorship", sponsor=self.sponsor, submited_by=self.user
453+
)
454+
self.subject_template = "sponsors/email/sponsor_expiring_assets_subject.txt"
455+
self.content_template = "sponsors/email/sponsor_expiring_assets.txt"
456+
457+
def test_send_email_using_correct_templates(self):
458+
context = {"sponsorship": self.sponsorship}
459+
expected_subject = render_to_string(self.subject_template, context).strip()
460+
expected_content = render_to_string(self.content_template, context).strip()
461+
462+
self.notification.notify(sponsorship=self.sponsorship)
463+
self.assertTrue(mail.outbox)
464+
465+
email = mail.outbox[0]
466+
self.assertEqual(expected_subject, email.subject)
467+
self.assertEqual(expected_content, email.body)
468+
self.assertEqual(settings.SPONSORSHIP_NOTIFICATION_FROM_EMAIL, email.from_email)
469+
self.assertCountEqual([self.user.email, self.verified_email.email], email.to)
470+
471+
def test_send_email_to_correct_recipients(self):
472+
context = {"user": self.user, "sponsorship": self.sponsorship}
473+
expected_contacts = ["[email protected]", self.verified_email.email]
474+
self.assertCountEqual(
475+
expected_contacts, self.notification.get_recipient_list(context)
476+
)
477+
478+
def test_list_required_assets_in_email_context(self):
479+
cfg = baker.make(RequiredTextAssetConfiguration, internal_name='input')
480+
benefit = baker.make(SponsorBenefit, sponsorship=self.sponsorship)
481+
asset = cfg.create_benefit_feature(benefit)
482+
base_context = {"sponsorship": self.sponsorship, "due_date": date.today()}
483+
context = self.notification.get_email_context(**base_context)
484+
self.assertEqual(3, len(context))
485+
self.assertEqual(self.sponsorship, context["sponsorship"])
486+
self.assertEqual(1, len(context["required_assets"]))
487+
self.assertEqual(date.today(), context["due_date"])
488+
self.assertIn(asset, context["required_assets"])

0 commit comments

Comments
 (0)